Skip to content

Personal Diary Application

Abstract

A personal diary is one of those projects where everything you build is genuinely useful. In this tutorial you will build a command-line journal that lets you write timestamped entries, tag each one with a mood, search through your past, filter by mood, and see stats on your journaling habit. Entries persist between runs via a JSON file you control entirely.

You will learn:

  • How to model entries as a class (and how @dataclass@dataclass cuts that boilerplate).
  • How to read and write JSON with json.dumpjson.dump / json.loadjson.load.
  • How to work with timestamps via datetimedatetime.
  • How to build a clean, menu-driven CLI without spaghetti.
  • How to add real privacy with optional encryption.

Prerequisites

  • Python 3.6 or above (3.7+ recommended for dataclassesdataclasses).
  • A text editor or IDE.
  • Comfort with functions, lists, and dictionaries.
  • Familiarity with running a .py.py file from the terminal.

Concepts You Will Use

ConceptPurpose
ClassGroup related data (entry fields) and behavior.
@dataclass@dataclassAuto-generate __init____init__, __repr____repr__, comparison — less boilerplate.
json.dumpjson.dump / json.loadjson.loadPersist Python objects to a text file as JSON.
datetimedatetimeTimestamp entries with date and time.
PathPath / os.path.existsos.path.existsDetect whether the diary file is new or existing.
List comprehensionsFilter and search entries cleanly.

Getting Started

Create the project

  1. Create a folder named personal-diarypersonal-diary.
  2. Inside it, create personaldiary.pypersonaldiary.py.
  3. (Optional) create diary.jsondiary.json — the script will create it for you on first save.

Write the code

Personal DiarySource
Personal Diary
# Personal Diary Application
 
import datetime
import os
import json
 
class DiaryEntry:
    def __init__(self, date, title, content, mood="neutral"):
        self.date = date
        self.title = title
        self.content = content
        self.mood = mood
    
    def to_dict(self):
        return {
            'date': self.date.isoformat(),
            'title': self.title,
            'content': self.content,
            'mood': self.mood
        }
    
    @classmethod
    def from_dict(cls, data):
        return cls(
            datetime.datetime.fromisoformat(data['date']),
            data['title'],
            data['content'],
            data.get('mood', 'neutral')
        )
 
class PersonalDiary:
    def __init__(self, filename="diary.json"):
        self.filename = filename
        self.entries = []
        self.load_entries()
    
    def load_entries(self):
        """Load entries from file"""
        if os.path.exists(self.filename):
            try:
                with open(self.filename, 'r') as f:
                    data = json.load(f)
                    self.entries = [DiaryEntry.from_dict(entry) for entry in data]
            except (json.JSONDecodeError, KeyError):
                print("Warning: Could not load diary entries. Starting fresh.")
                self.entries = []
    
    def save_entries(self):
        """Save entries to file"""
        with open(self.filename, 'w') as f:
            json.dump([entry.to_dict() for entry in self.entries], f, indent=2)
    
    def add_entry(self, title, content, mood="neutral"):
        """Add a new diary entry"""
        entry = DiaryEntry(datetime.datetime.now(), title, content, mood)
        self.entries.append(entry)
        self.save_entries()
        print("Entry added successfully!")
    
    def view_entries(self):
        """Display all diary entries"""
        if not self.entries:
            print("No diary entries found.")
            return
        
        print("\n" + "="*50)
        print("YOUR DIARY ENTRIES")
        print("="*50)
        
        for i, entry in enumerate(self.entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def search_entries(self, keyword):
        """Search entries by keyword"""
        found_entries = []
        keyword_lower = keyword.lower()
        
        for entry in self.entries:
            if (keyword_lower in entry.title.lower() or 
                keyword_lower in entry.content.lower()):
                found_entries.append(entry)
        
        if not found_entries:
            print(f"No entries found containing '{keyword}'")
            return
        
        print(f"\nFound {len(found_entries)} entries containing '{keyword}':")
        print("="*50)
        
        for i, entry in enumerate(found_entries, 1):
            print(f"\nResult #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def filter_by_mood(self, mood):
        """Filter entries by mood"""
        mood_entries = [entry for entry in self.entries if entry.mood.lower() == mood.lower()]
        
        if not mood_entries:
            print(f"No entries found with mood '{mood}'")
            return
        
        print(f"\nEntries with mood '{mood}':")
        print("="*50)
        
        for i, entry in enumerate(mood_entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def get_statistics(self):
        """Display diary statistics"""
        if not self.entries:
            print("No entries to analyze.")
            return
        
        total_entries = len(self.entries)
        mood_counts = {}
        
        for entry in self.entries:
            mood = entry.mood
            mood_counts[mood] = mood_counts.get(mood, 0) + 1
        
        print(f"\nDiary Statistics:")
        print("="*30)
        print(f"Total entries: {total_entries}")
        print(f"First entry: {min(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"Latest entry: {max(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"\nMood distribution:")
        for mood, count in mood_counts.items():
            percentage = (count / total_entries) * 100
            print(f"  {mood}: {count} ({percentage:.1f}%)")
 
def main():
    diary = PersonalDiary()
    
    while True:
        print("\n" + "="*40)
        print("PERSONAL DIARY APPLICATION")
        print("="*40)
        print("1. Add new entry")
        print("2. View all entries")
        print("3. Search entries")
        print("4. Filter by mood")
        print("5. View statistics")
        print("6. Exit")
        
        choice = input("\nSelect an option (1-6): ").strip()
        
        if choice == '1':
            print("\nAdding new diary entry:")
            title = input("Enter title: ").strip()
            print("Enter content (press Enter twice to finish):")
            content_lines = []
            while True:
                line = input()
                if line == "":
                    break
                content_lines.append(line)
            content = "\n".join(content_lines)
            
            print("Select mood:")
            print("1. Happy  2. Sad  3. Excited  4. Anxious  5. Peaceful  6. Other")
            mood_choice = input("Enter choice (1-6): ").strip()
            
            mood_map = {'1': 'happy', '2': 'sad', '3': 'excited', 
                       '4': 'anxious', '5': 'peaceful', '6': 'other'}
            mood = mood_map.get(mood_choice, 'neutral')
            
            if mood == 'other':
                mood = input("Enter custom mood: ").strip() or 'neutral'
            
            diary.add_entry(title, content, mood)
        
        elif choice == '2':
            diary.view_entries()
        
        elif choice == '3':
            keyword = input("Enter search keyword: ").strip()
            if keyword:
                diary.search_entries(keyword)
        
        elif choice == '4':
            mood = input("Enter mood to filter by: ").strip()
            if mood:
                diary.filter_by_mood(mood)
        
        elif choice == '5':
            diary.get_statistics()
        
        elif choice == '6':
            print("Thank you for using Personal Diary. Goodbye!")
            break
        
        else:
            print("Invalid choice. Please try again.")
 
if __name__ == "__main__":
    main()
 
Personal Diary
# Personal Diary Application
 
import datetime
import os
import json
 
class DiaryEntry:
    def __init__(self, date, title, content, mood="neutral"):
        self.date = date
        self.title = title
        self.content = content
        self.mood = mood
    
    def to_dict(self):
        return {
            'date': self.date.isoformat(),
            'title': self.title,
            'content': self.content,
            'mood': self.mood
        }
    
    @classmethod
    def from_dict(cls, data):
        return cls(
            datetime.datetime.fromisoformat(data['date']),
            data['title'],
            data['content'],
            data.get('mood', 'neutral')
        )
 
class PersonalDiary:
    def __init__(self, filename="diary.json"):
        self.filename = filename
        self.entries = []
        self.load_entries()
    
    def load_entries(self):
        """Load entries from file"""
        if os.path.exists(self.filename):
            try:
                with open(self.filename, 'r') as f:
                    data = json.load(f)
                    self.entries = [DiaryEntry.from_dict(entry) for entry in data]
            except (json.JSONDecodeError, KeyError):
                print("Warning: Could not load diary entries. Starting fresh.")
                self.entries = []
    
    def save_entries(self):
        """Save entries to file"""
        with open(self.filename, 'w') as f:
            json.dump([entry.to_dict() for entry in self.entries], f, indent=2)
    
    def add_entry(self, title, content, mood="neutral"):
        """Add a new diary entry"""
        entry = DiaryEntry(datetime.datetime.now(), title, content, mood)
        self.entries.append(entry)
        self.save_entries()
        print("Entry added successfully!")
    
    def view_entries(self):
        """Display all diary entries"""
        if not self.entries:
            print("No diary entries found.")
            return
        
        print("\n" + "="*50)
        print("YOUR DIARY ENTRIES")
        print("="*50)
        
        for i, entry in enumerate(self.entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def search_entries(self, keyword):
        """Search entries by keyword"""
        found_entries = []
        keyword_lower = keyword.lower()
        
        for entry in self.entries:
            if (keyword_lower in entry.title.lower() or 
                keyword_lower in entry.content.lower()):
                found_entries.append(entry)
        
        if not found_entries:
            print(f"No entries found containing '{keyword}'")
            return
        
        print(f"\nFound {len(found_entries)} entries containing '{keyword}':")
        print("="*50)
        
        for i, entry in enumerate(found_entries, 1):
            print(f"\nResult #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Mood: {entry.mood}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def filter_by_mood(self, mood):
        """Filter entries by mood"""
        mood_entries = [entry for entry in self.entries if entry.mood.lower() == mood.lower()]
        
        if not mood_entries:
            print(f"No entries found with mood '{mood}'")
            return
        
        print(f"\nEntries with mood '{mood}':")
        print("="*50)
        
        for i, entry in enumerate(mood_entries, 1):
            print(f"\nEntry #{i}")
            print(f"Date: {entry.date.strftime('%Y-%m-%d %H:%M')}")
            print(f"Title: {entry.title}")
            print(f"Content: {entry.content}")
            print("-" * 30)
    
    def get_statistics(self):
        """Display diary statistics"""
        if not self.entries:
            print("No entries to analyze.")
            return
        
        total_entries = len(self.entries)
        mood_counts = {}
        
        for entry in self.entries:
            mood = entry.mood
            mood_counts[mood] = mood_counts.get(mood, 0) + 1
        
        print(f"\nDiary Statistics:")
        print("="*30)
        print(f"Total entries: {total_entries}")
        print(f"First entry: {min(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"Latest entry: {max(self.entries, key=lambda x: x.date).date.strftime('%Y-%m-%d')}")
        print(f"\nMood distribution:")
        for mood, count in mood_counts.items():
            percentage = (count / total_entries) * 100
            print(f"  {mood}: {count} ({percentage:.1f}%)")
 
def main():
    diary = PersonalDiary()
    
    while True:
        print("\n" + "="*40)
        print("PERSONAL DIARY APPLICATION")
        print("="*40)
        print("1. Add new entry")
        print("2. View all entries")
        print("3. Search entries")
        print("4. Filter by mood")
        print("5. View statistics")
        print("6. Exit")
        
        choice = input("\nSelect an option (1-6): ").strip()
        
        if choice == '1':
            print("\nAdding new diary entry:")
            title = input("Enter title: ").strip()
            print("Enter content (press Enter twice to finish):")
            content_lines = []
            while True:
                line = input()
                if line == "":
                    break
                content_lines.append(line)
            content = "\n".join(content_lines)
            
            print("Select mood:")
            print("1. Happy  2. Sad  3. Excited  4. Anxious  5. Peaceful  6. Other")
            mood_choice = input("Enter choice (1-6): ").strip()
            
            mood_map = {'1': 'happy', '2': 'sad', '3': 'excited', 
                       '4': 'anxious', '5': 'peaceful', '6': 'other'}
            mood = mood_map.get(mood_choice, 'neutral')
            
            if mood == 'other':
                mood = input("Enter custom mood: ").strip() or 'neutral'
            
            diary.add_entry(title, content, mood)
        
        elif choice == '2':
            diary.view_entries()
        
        elif choice == '3':
            keyword = input("Enter search keyword: ").strip()
            if keyword:
                diary.search_entries(keyword)
        
        elif choice == '4':
            mood = input("Enter mood to filter by: ").strip()
            if mood:
                diary.filter_by_mood(mood)
        
        elif choice == '5':
            diary.get_statistics()
        
        elif choice == '6':
            print("Thank you for using Personal Diary. Goodbye!")
            break
        
        else:
            print("Invalid choice. Please try again.")
 
if __name__ == "__main__":
    main()
 

Run it

run
python personaldiary.py
run
python personaldiary.py
text
1. Add new entry
2. View all entries
3. Search entries
4. Filter by mood
5. View statistics
6. Exit
Choice: 1
Title: First entry
Mood (happy/sad/neutral/excited/anxious): happy
Content (end with empty line):
> Started my Python diary today.
>
Saved.
text
1. Add new entry
2. View all entries
3. Search entries
4. Filter by mood
5. View statistics
6. Exit
Choice: 1
Title: First entry
Mood (happy/sad/neutral/excited/anxious): happy
Content (end with empty line):
> Started my Python diary today.
>
Saved.

Step-by-Step Explanation

1. The data model

personaldiary.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
 
@dataclass
class DiaryEntry:
    title: str
    content: str
    mood: str = "neutral"
    date: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
 
    def to_dict(self):
        return asdict(self)
 
    @classmethod
    def from_dict(cls, d):
        return cls(**d)
personaldiary.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
 
@dataclass
class DiaryEntry:
    title: str
    content: str
    mood: str = "neutral"
    date: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
 
    def to_dict(self):
        return asdict(self)
 
    @classmethod
    def from_dict(cls, d):
        return cls(**d)

Why @dataclass@dataclass? In one decorator you get:

  • A real __init____init__ that accepts each field.
  • A __repr____repr__ for clean printing.
  • Equality based on field values.
  • Optional default values.

field(default_factory=...)field(default_factory=...) is the right way to default a mutable or computed value — it runs each time a new entry is created, so every entry gets its own current timestamp.

2. The Diary class

personaldiary.py
import json
from pathlib import Path
 
class Diary:
    def __init__(self, filename="diary.json"):
        self.filename = Path(filename)
        self.entries: list[DiaryEntry] = []
        self.load()
 
    def load(self):
        if self.filename.exists():
            data = json.loads(self.filename.read_text(encoding="utf-8"))
            self.entries = [DiaryEntry.from_dict(d) for d in data]
 
    def save(self):
        data = [e.to_dict() for e in self.entries]
        self.filename.write_text(json.dumps(data, indent=2), encoding="utf-8")
 
    def add(self, entry: DiaryEntry):
        self.entries.append(entry)
        self.save()
personaldiary.py
import json
from pathlib import Path
 
class Diary:
    def __init__(self, filename="diary.json"):
        self.filename = Path(filename)
        self.entries: list[DiaryEntry] = []
        self.load()
 
    def load(self):
        if self.filename.exists():
            data = json.loads(self.filename.read_text(encoding="utf-8"))
            self.entries = [DiaryEntry.from_dict(d) for d in data]
 
    def save(self):
        data = [e.to_dict() for e in self.entries]
        self.filename.write_text(json.dumps(data, indent=2), encoding="utf-8")
 
    def add(self, entry: DiaryEntry):
        self.entries.append(entry)
        self.save()

Reading and writing the whole file each time is fine for personal use — JSON is small and humans like being able to open it in a text editor. If you ever had millions of entries you would swap to SQLite.

3. Search and filter

personaldiary.py
def search(self, keyword: str) -> list[DiaryEntry]:
    k = keyword.lower()
    return [e for e in self.entries
            if k in e.title.lower() or k in e.content.lower()]
 
def by_mood(self, mood: str) -> list[DiaryEntry]:
    return [e for e in self.entries if e.mood == mood]
personaldiary.py
def search(self, keyword: str) -> list[DiaryEntry]:
    k = keyword.lower()
    return [e for e in self.entries
            if k in e.title.lower() or k in e.content.lower()]
 
def by_mood(self, mood: str) -> list[DiaryEntry]:
    return [e for e in self.entries if e.mood == mood]

List comprehensions read like English. The lower()lower() calls make search case-insensitive.

4. Statistics

personaldiary.py
from collections import Counter
 
def stats(self):
    if not self.entries:
        return {"total": 0, "moods": {}}
    moods = Counter(e.mood for e in self.entries)
    return {
        "total": len(self.entries),
        "moods": {m: f"{c} ({c/len(self.entries):.1%})"
                  for m, c in moods.most_common()},
        "first": self.entries[0].date,
        "latest": self.entries[-1].date,
    }
personaldiary.py
from collections import Counter
 
def stats(self):
    if not self.entries:
        return {"total": 0, "moods": {}}
    moods = Counter(e.mood for e in self.entries)
    return {
        "total": len(self.entries),
        "moods": {m: f"{c} ({c/len(self.entries):.1%})"
                  for m, c in moods.most_common()},
        "first": self.entries[0].date,
        "latest": self.entries[-1].date,
    }

CounterCounter is the right tool any time you find yourself reaching for dictdict plus +=+=.

5. The menu loop

personaldiary.py
def main():
    diary = Diary()
    while True:
        print("\n1. Add  2. View  3. Search  4. By mood  5. Stats  6. Exit")
        choice = input("Choice: ").strip()
        if choice == "1":
            add_entry(diary)
        elif choice == "2":
            view_all(diary)
        elif choice == "3":
            search_entries(diary)
        elif choice == "4":
            filter_by_mood(diary)
        elif choice == "5":
            show_stats(diary)
        elif choice == "6":
            break
        else:
            print("Invalid choice.")
personaldiary.py
def main():
    diary = Diary()
    while True:
        print("\n1. Add  2. View  3. Search  4. By mood  5. Stats  6. Exit")
        choice = input("Choice: ").strip()
        if choice == "1":
            add_entry(diary)
        elif choice == "2":
            view_all(diary)
        elif choice == "3":
            search_entries(diary)
        elif choice == "4":
            filter_by_mood(diary)
        elif choice == "5":
            show_stats(diary)
        elif choice == "6":
            break
        else:
            print("Invalid choice.")

Each menu option calls a separate helper function. Each helper does one thing. The main loop only routes.

6. Multi-line content

personaldiary.py
def read_multiline(prompt: str) -> str:
    print(prompt + " (end with an empty line)")
    lines = []
    while True:
        line = input("> ")
        if line == "":
            break
        lines.append(line)
    return "\n".join(lines)
personaldiary.py
def read_multiline(prompt: str) -> str:
    print(prompt + " (end with an empty line)")
    lines = []
    while True:
        line = input("> ")
        if line == "":
            break
        lines.append(line)
    return "\n".join(lines)

This pattern lets users write paragraphs, not just one line. Press Enter on an empty line to finish.

Common Mistakes

ProblemCauseFix
json.decoder.JSONDecodeErrorjson.decoder.JSONDecodeError on first runFile exists but is emptyCheck if self.filename.exists() and self.filename.stat().st_size > 0if self.filename.exists() and self.filename.stat().st_size > 0
Entries lost after restartForgot self.save()self.save() after addingSave on every mutating operation, not just exit
Unicode errorsDefault encoding mismatchAlways pass encoding="utf-8"encoding="utf-8" to read/write
KeyErrorKeyError after upgrading the schemaOld file lacks a new fieldUse .get().get() in from_dictfrom_dict or run a one-shot migration
Search returns nothingCase mismatch.lower().lower() both sides

Variations to Try

1. Edit and delete entries

Number entries when displaying them; let the user type the number to edit or delete.

2. Encrypted entries

A diary should be private. Use cryptography.fernetcryptography.fernet:

encrypt.py
from cryptography.fernet import Fernet
key = Fernet(...).generate_key()           # store separately!
cipher = Fernet(key)
encrypted = cipher.encrypt(content.encode())
encrypt.py
from cryptography.fernet import Fernet
key = Fernet(...).generate_key()           # store separately!
cipher = Fernet(key)
encrypted = cipher.encrypt(content.encode())

Save encryptedencrypted instead of the raw content. Decrypt on load. Better: derive the key from a user-provided password using PBKDF2HMACPBKDF2HMAC.

3. Date filtering

“Show entries from last week” or “from 2025-01-01 to 2025-01-31”.

date_range.py
from datetime import datetime
start = datetime.fromisoformat("2025-01-01")
end   = datetime.fromisoformat("2025-01-31")
return [e for e in self.entries
        if start <= datetime.fromisoformat(e.date) <= end]
date_range.py
from datetime import datetime
start = datetime.fromisoformat("2025-01-01")
end   = datetime.fromisoformat("2025-01-31")
return [e for e in self.entries
        if start <= datetime.fromisoformat(e.date) <= end]

4. Tags / categories

Add a tags: list[str]tags: list[str] field; let the user filter by tag the same way as by mood.

5. Sentiment analysis

Use TextBlob or VADER to compute a sentiment score per entry; correlate it with your self-reported mood.

6. Markdown rendering

Save entries as Markdown. Export the diary as a single Markdown or HTML file you can print.

7. GUI version

Build a Tkinter UI with a list of dates on the left and the content on the right.

8. Cloud backup

Sync the diary.jsondiary.json file to S3 / Google Drive on save. Encrypt before upload.

9. Mood chart

Use matplotlib to plot moods over time:

chart.py
import matplotlib.pyplot as plt
dates = [e.date for e in diary.entries]
moods = [e.mood for e in diary.entries]
plt.plot(dates, moods, marker="o")
plt.xticks(rotation=45)
plt.tight_layout(); plt.show()
chart.py
import matplotlib.pyplot as plt
dates = [e.date for e in diary.entries]
moods = [e.mood for e in diary.entries]
plt.plot(dates, moods, marker="o")
plt.xticks(rotation=45)
plt.tight_layout(); plt.show()

10. Reminder

Schedule a daily prompt at 9 PM to write today’s entry. See Basic Alarm Clock for the scheduling idea.

Privacy Considerations

A diary on disk is plaintext by default. If you keep anything personal:

  • Move the diary file out of any auto-synced folder (Dropbox, OneDrive, iCloud) unless those services are encrypted at rest with your key.
  • Encrypt at rest with cryptography.fernetcryptography.fernet and a password-derived key.
  • Never commit your diary file to Git. Add diary.jsondiary.json to .gitignore.gitignore.
  • Back up the file somewhere safe — losing it is permanent.

Real-World Applications

  • Personal journaling — what this is.
  • Mood / habit tracking — bullet-journal-style logs.
  • Reading log or workout log — same data structure, different fields.
  • Customer-service ticketing prototypes — entries are tickets, moods are priorities.
  • Bug-tracking for solo projects.

Educational Value

  • Data modeling with @dataclass@dataclass.
  • Persistence with JSON, including schema evolution.
  • Searching and filtering with list comprehensions.
  • Counting and aggregation with CounterCounter.
  • CLI design — clear menus, helper functions, no global state.

Next Steps

  • Build the encryption layer (cryptography.fernetcryptography.fernet).
  • Add a edit/delete flow.
  • Plot mood over time with matplotlib.
  • Wrap with a Tkinter GUI or a Flask web app.
  • Migrate storage from JSON to SQLite when you cross a few thousand entries.

Conclusion

You built a real productivity tool: timestamped, searchable, statistically aware. The same architecture (data class + persistence + menu loop) underlies thousands of small CLI tools used in the wild. The full source is on GitHub. Explore more on Python Central Hub.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did