Build a local command-line tool that records study sessions and reports their total duration. This project combines functions, collections, modules, validation, files, and tests. Unlike the HTTP exercise, it has no server or network dependency. Its deliberately small boundary is one person running one command at a time against a local file.
Define the behavior first
A session contains a nonempty topic and a positive integer duration. add records one session; summary reads without writing. A missing file represents a new tracker. Invalid existing data must produce an error instead of being overwritten. Commands should work independently, with the file providing continuity between processes.
Create a new tracker-project directory. Save the entire implementation below as tracker.py. Keep calculation and storage functions importable; only main handles command-line arguments and terminal output.
import argparse
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
def validate(rows: object) -> list[dict[str, object]]:
if not isinstance(rows, list):
raise ValueError("sessions must be a list")
for row in rows:
if not isinstance(row, dict) or set(row) != {"topic", "minutes"}:
raise ValueError("each session needs topic and minutes only")
topic, minutes = row["topic"], row["minutes"]
if not isinstance(topic, str) or not topic.strip():
raise ValueError("topic must be nonempty text")
if type(minutes) is not int or minutes <= 0:
raise ValueError("minutes must be a positive integer")
return rows
def load(path: Path) -> list[dict[str, object]]:
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return []
return validate(json.loads(text))
def save(path: Path, rows: object) -> None:
text = json.dumps(validate(rows), ensure_ascii=False, indent=2) + "\n"
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as target:
temporary = Path(target.name)
target.write(text)
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Track study sessions")
parser.add_argument("--file", type=Path, default=Path("sessions.json"))
commands = parser.add_subparsers(dest="command", required=True)
add = commands.add_parser("add")
add.add_argument("topic")
add.add_argument("minutes", type=int)
commands.add_parser("summary")
args = parser.parse_args()
try:
rows = load(args.file)
if args.command == "add":
rows.append({"topic": args.topic.strip(), "minutes": args.minutes})
save(args.file, rows)
total = sum(row["minutes"] for row in rows)
print(f"Sessions: {len(rows)}; minutes: {total}")
except (OSError, ValueError) as error:
parser.exit(2, f"error: {error}\n")
if __name__ == "__main__":
main()
The argparse reference explains subcommands and generated help. Put the global --file option before the subcommand. The parser checks integer text; validate checks application rules and decoded file contents. This separation protects the same rules whether input comes from the terminal or JSON.
Run an acceptance check
In a fresh directory without an existing sessions.json, run these commands in order:
python tracker.py summary
python tracker.py add "Python loops" 25
python tracker.py add "JSON files" 35
python tracker.py summary
python tracker.py --file practice.json summary
Expect totals of 0, 25, 60, 60, and 0, with session counts 0, 1, 2, 2, and 0. The last command does not create practice.json. Quoting a topic containing spaces makes it one argument. Use python tracker.py --help and python tracker.py add --help to inspect usage.
Now try adding zero minutes or a blank topic. Each command should exit with status 2, and the stored sessions should remain unchanged. Repeating a valid add deliberately records another session; it is not a deduplicating operation.
Test the storage contract
Save this as test_tracker.py, next to the implementation:
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from tracker import load, save
class StorageTests(unittest.TestCase):
def test_round_trip(self):
with TemporaryDirectory() as directory:
path = Path(directory) / "sessions.json"
self.assertEqual(load(path), [])
rows = [{"topic": "loops", "minutes": 25}]
save(path, rows)
self.assertEqual(load(path), rows)
def test_invalid_save_preserves_existing_file(self):
with TemporaryDirectory() as directory:
path = Path(directory) / "sessions.json"
rows = [{"topic": "files", "minutes": 35}]
save(path, rows)
before = path.read_bytes()
with self.assertRaises(ValueError):
save(path, [{"topic": "files", "minutes": True}])
self.assertEqual(path.read_bytes(), before)
Run python -m unittest discover -v; expect two passing tests. These tests exercise actual files in temporary directories, not your personal tracker. Add a command-line test later using subprocess.run if you want to check exit status and terminal messages automatically.
Explain the storage boundary
The temporary file is created beside the destination, closed, then used to replace it. This avoids truncating the old destination while writing new contents. Temporary-file behavior and replacement semantics matter across operating systems.
Replacement is not a transaction across concurrent read-modify-write commands. Two commands could read the same old file and the later save could lose the other's update. It also does not provide full power-loss durability. Keep this a single-writer local tool; use coordinated storage such as SQLite when the requirements expand. Reading the full JSON file is appropriate only while it remains small.
Exercises and solution guidance
Exercise: add a report of minutes grouped by topic, and add a regression test proving that malformed existing JSON is never overwritten by an add attempt.
Solution guidance: accumulate totals in a dictionary and print topic names in sorted order. Use temporary storage for the corruption test, write broken JSON, run the command, and compare bytes before and after. For the sample data, expect 25 minutes for Python loops and 35 for JSON files. Test empty input, repeated topics, Unicode names, and invalid durations as separate cases.
Finish by explaining the path from arguments to validation to storage in your own words. If you can predict a command's result, find its failure boundary, and add a focused test, you have a practical foundation for larger Python applications.
Review: Python Setup and Virtual Environments or the Python overview.

