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.
You only need Python 3.10+ (for match/case). No external packages are required — the whole program uses the standard library.
$ python --version
$ python taskbook.py
# 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.
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.
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
@log_errors above def add replaces add with wrapper.
- When you call
book.add(...), you're actually calling wrapper.
wrapper tries to run the original add inside a try/except.
- If an exception is raised, it's caught and printed — no crash.
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}")
@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.
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())
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.
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()
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.