Python Learning Project

TaskBook

A single, fully-runnable program that covers nearly every core feature of Python — built around a practical CLI task manager so every concept has a real purpose.

00

What the program does

taskbook.py is a command-line task manager. You can add tasks with priorities and tags, mark them done, search them, and view statistics. All data is saved to a tasks.json file so it persists between runs.

Every feature of the app exists to exercise a real Python concept. Nothing is contrived — the program is genuinely useful, and every language feature earns its place.

Variables & Types

Every constant, task field, and menu choice.

Dicts, Lists, Sets

The menu dispatch table, task list, priority set.

OOP — Class

TaskBook manages the collection with methods and a property.

OOP — Dataclass

Task uses @dataclass for auto-generated boilerplate.

Decorator

@log_errors wraps methods to catch and display errors cleanly.

Generator

book.pending() yields tasks lazily instead of building a list.

Comprehensions

Filtering, transforming, and serialising task lists in one line.

File I/O + JSON

Tasks are saved and loaded with open() and json.

Exception Handling

Invalid input and corrupt files are caught gracefully.

match / case

The main menu uses Python 3.10+ pattern matching.

Lambda & sorted()

Sorting tasks by priority or title with a key function.

Standard Library

pathlib, datetime, functools, collections all appear naturally.

01

How to run it

You only need Python 3.10+ (for match/case). No external packages are required — the whole program uses the standard library.

$ python --version # confirm Python 3.10+
$ python taskbook.py # run the program
# On first run, it creates tasks.json in the same directory.
# Type 'a' to add a task. Type 'q' to quit.
If you're on Python 3.9 or earlier, the match/case block in main() can be replaced with a standard if/elif chain — everything else will work unchanged.
02

Constants & module-level setup

The top of the file defines the program's fixed values. These run once when the module is imported. Notice the three different collection types being used for three different purposes:

# pathlib.Path is an object — smarter than a plain string
DATA_FILE   = Path("tasks.json")

# set — unordered, unique values. Perfect for "valid choices".
PRIORITIES  = {"low", "medium", "high"}

# dict — maps priority name → display symbol
PRIORITY_EMOJI = {
    "low":    "○",
    "medium": "◐",
    "high":   "●",
}

# tuple of pairs — immutable, fixed data, good for (label, value) tables
COLOURS = (
    ("green",  "\033[92m"),
    ("reset",  "\033[0m"),
    # ...
)

# dict comprehension builds a lookup from the tuple above
# {name: code for name, code in COLOURS}  →  {"green": "\033[92m", ...}
C = {name: code for name, code in COLOURS}
Naming convention: UPPER_CASE signals "treat this as a constant." Python doesn't enforce immutability at the language level — it's a social contract enforced by convention and linters.
set {}
Unordered, unique values. Use for fast membership tests (if x in PRIORITIES).
dict {}
Key → value mapping. O(1) lookup. Keys must be hashable.
tuple ()
Immutable sequence. Use when data shouldn't change.
list []
Mutable ordered sequence. Use when you need to add/remove.
03

The decorator

A decorator is a function that wraps another function to add behaviour before or after it runs — without modifying the original function's code. In this program, @log_errors catches exceptions from any method it wraps and prints them cleanly, so the program never crashes on bad user input.

def log_errors(func):
    """The decorator factory — receives the function to wrap."""
    @functools.wraps(func)    # copies func's name/docstring onto wrapper
    def wrapper(*args, **kwargs):
        # *args  = extra positional args bundled into a tuple
        # **kwargs = extra keyword args bundled into a dict
        try:
            return func(*args, **kwargs)   # call the original function
        except (ValueError, KeyError) as e:
            print(f"  Error: {e}")
    return wrapper              # return the wrapper, not the result

# Applied with @ syntax — exactly equivalent to:  add = log_errors(add)
@log_errors
def add(self, title, ...):
    ...
How it flows
  1. @log_errors above def add replaces add with wrapper.
  2. When you call book.add(...), you're actually calling wrapper.
  3. wrapper tries to run the original add inside a try/except.
  4. If an exception is raised, it's caught and printed — no crash.
  5. functools.wraps keeps the original function's name in tracebacks.
Without the decorator
# Every method would need this boilerplate:
def add(self, title):
    try:
        # actual logic
        ...
    except (ValueError, KeyError) as e:
        print(f"Error: {e}")

def remove(self, idx):
    try:
        # actual logic
        ...
    except (ValueError, KeyError) as e:
        print(f"Error: {e}")
04

The Task dataclass

@dataclass is a decorator from the standard library that automatically generates __init__, __repr__, and __eq__ from your field annotations. It's the modern, concise way to define data-holding classes.

from dataclasses import dataclass, field

@dataclass
class Task:
    title:    str                  # required field — no default
    priority: str  = "medium"   # optional field — has default
    done:     bool = False
    tags:     list = field(default_factory=list)
    # ↑ IMPORTANT: never write  tags: list = []
    #   That would share ONE list across all Task instances!
    #   field(default_factory=list) creates a fresh [] for each task.

    created:  str = field(
        default_factory=lambda: datetime.datetime.now().strftime("%Y-%m-%d")
        # lambda = anonymous single-expression function
        # called at Task() creation time to capture the current date
    )

Methods on the dataclass

    def to_dict(self) -> dict:
        # vars(self) returns the instance's __dict__
        return vars(self)

    @classmethod                           # receives the class, not an instance
    def from_dict(cls, data: dict) -> "Task":
        return cls(**data)                  # ** unpacks dict as keyword args

    def __str__(self) -> str:
        # __str__ is called by print() and str()
        status = f"✓" if self.done else f"·"   # inline ternary
        tag_str = " ".join(f"#{t}" for t in self.tags)  # generator expr
        return f"{status} {self.title} {tag_str}"
The mutable default gotcha is one of the most common Python bugs. tags: list = [] creates one list object shared by every instance — appending to one task's tags would append to all of them. field(default_factory=list) creates a new [] for each instance. Always use field(default_factory=...) for lists and dicts.
05

The TaskBook class

TaskBook is a traditional class (not a dataclass) because it needs custom logic in __init__, a computed property, and a generator method. It wraps the list of tasks and exposes clean methods for every operation.

__init__ and instance variables

class TaskBook:
    _instance_count: int = 0   # class variable — shared by ALL instances

    def __init__(self, filepath: Path):
        self.filepath = filepath    # instance variable — unique to this object
        self.tasks: list[Task] = [] # empty list, typed for IDE support
        TaskBook._instance_count += 1
        self._load()               # _ prefix = "private by convention"

@property — computed attributes

    @property
    def stats(self) -> dict:
        # @property lets callers write  book.stats  instead of  book.stats()
        # It looks like an attribute but runs code every time.
        total = len(self.tasks)
        done  = sum(1 for t in self.tasks if t.done)  # generator expression
        by_priority = Counter(t.priority for t in self.tasks)
        return {"total": total, "done": done, "pending": total - done, ...}

Generators — lazy iteration with yield

    def pending(self):
        # 'yield' makes this a generator function.
        # It produces values one at a time — nothing is computed until
        # the caller asks for the next item.
        for task in self.tasks:
            if not task.done:
                yield task

    # Or as a generator expression (one-liner form):
    def by_priority(self, priority):
        return (t for t in self.tasks if t.priority == priority)

File I/O with context manager

    def _save(self) -> None:
        # 'with open(...) as f' is a context manager.
        # It guarantees the file is closed even if an exception is raised.
        with open(self.filepath, "w", encoding="utf-8") as f:
            # list comprehension serialises each Task to a plain dict
            json.dump([t.to_dict() for t in self.tasks], f, indent=2)

    def _load(self) -> None:
        if not self.filepath.exists():  # pathlib method — cleaner than os.path
            return
        try:
            with open(self.filepath, "r", encoding="utf-8") as f:
                raw = json.load(f)
            self.tasks = [Task.from_dict(d) for d in raw]
        except json.JSONDecodeError:
            print("Warning: tasks.json is corrupt — starting fresh.")
            self.tasks = []

Sorting with lambda

    def sorted_by(self, key: str = "priority") -> list[Task]:
        priority_order = {"high": 0, "medium": 1, "low": 2}
        if key == "priority":
            # sorted() returns a NEW list (non-destructive)
            # key= receives a function called on each element to produce the sort key
            # lambda t: ... is an anonymous function: "given task t, return..."
            return sorted(self.tasks,
                          key=lambda t: priority_order.get(t.priority, 99))
        elif key == "title":
            return sorted(self.tasks, key=lambda t: t.title.lower())
06

Standalone functions

Not everything belongs in a class. Pure functions — functions with no side effects that depend only on their arguments — are easier to test and reuse. The display and input functions are kept separate from TaskBook deliberately.

Default arguments & *args / **kwargs

def display_tasks(tasks: list[Task], heading: str = "Tasks") -> None:
    # 'heading' has a default — callers can omit it
    # enumerate(tasks, start=1) gives (1, task), (2, task), ...
    for i, task in enumerate(tasks, start=1):
        print(f"  {i}. {task}")

def parse_tags(raw: str) -> list[str]:
    # List comprehension with filter and transform in one expression:
    # [expression  for item in iterable  if condition]
    return [t.strip().lower() for t in raw.split(",") if t.strip()]

def prompt(message: str, default: str = "") -> str:
    # Walrus operator (:=) assigns and uses in one expression
    hint = f" [{default}]" if default else ""
    raw = input(f"  {message}{hint}: ").strip()
    return raw or default   # 'or default' returns default if raw is empty
or as a default fallback: raw or default returns default when raw is any falsy value — empty string, None, 0, etc. This is extremely common Python idiom. The longer version would be default if not raw else raw.
07

The main loop & dispatch table

The main() function runs the interactive loop. Two important Python patterns appear here: the dispatch table (a dict of functions) and match/case pattern matching.

Dispatch table — functions as values

# Functions are first-class objects in Python.
# They can be stored in a dict just like any other value.
dispatch: dict[str, callable] = {
    "a": run_add,
    "l": run_list,
    "d": run_complete,
    "x": run_remove,
    # ...
}

# Call the right handler based on user input — no long if/elif chain
handler = dispatch[choice]     # look up function by key
handler(book)                  # call it with the TaskBook

match / case — structural pattern matching

match choice:
    case "q":
        break                              # exit the while loop

    case "" | "?":
        continue                           # | = OR — match either

    case cmd if cmd in dispatch:
        # guard clause — only matches if the condition is True
        dispatch[cmd](book)

    case _:
        # _ is the wildcard — matches anything not caught above
        print(f"Unknown command '{choice}'")

The script guard

# When Python runs a file directly, __name__ == "__main__"
# When the file is imported as a module, __name__ is the module name.
# This guard ensures main() only runs when executed directly.

if __name__ == "__main__":
    main()
08

All concepts — where to find them

Every concept is annotated with a comment in the source file. Use this table as a navigation guide when reading taskbook.py.

Variables & types
Section 2 — DATA_FILE, PRIORITIES, C colour dict
f-strings
Throughout — e.g. f"Task #{i}: {task.title}"
list / dict / set / tuple
Section 2 — constants block; TaskBook.tasks (list); dispatch (dict)
Comprehensions
parse_tags, _save, search, clear_done, stats
Generator / yield
TaskBook.pending() and by_priority()
if / elif / else
Every handler function; sorted_by; _load
for / while
display_tasks (for); main() (while True)
match / case
main() — the menu dispatch block
Functions + defaults
display_tasks, prompt, parse_tags
*args / **kwargs
log_errors wrapper; Task.from_dict uses **data
lambda
sorted_by sort keys; Task.created default_factory
Decorator
log_errors — Section 3; applied with @log_errors
Dataclass
Task class — Section 4
Class + __init__
TaskBook — Section 5
@property
TaskBook.stats and TaskBook.count
@classmethod
Task.from_dict
Exception handling
log_errors; _load; run_complete input parsing
raise
TaskBook.add — validation logic
File I/O + with
_save and _load
json
json.dump / json.load in _save / _load
pathlib
DATA_FILE = Path(...); filepath.exists()
datetime
Task.created default_factory
collections.Counter
TaskBook.stats — counting tags and priorities
functools.wraps
log_errors decorator
Type hints
Every function signature; Optional[list] in add()
__str__ / __repr__
Task.__str__ and Task.__repr__
if __name__ == "__main__"
Bottom of the file — script entry point guard
Suggested learning path: Run the program first to see it work. Then read taskbook.py from top to bottom — every section is labelled and every non-obvious line has a comment. Try making a small change at each step: add a new priority level, add a new command, change the display format. Breaking things and fixing them is the fastest way to learn.