Capstone Goals
3 minBuild a single CLI app that demonstrates mastery of:
- Collections. List of dicts for notes; nested fields for tags.
- Strings. f-strings for display,
split/joinfor tags. - Random. A "daily highlight" that picks one random note.
- Files & JSON. Save and load the whole structure as
notes.json. - Errors. Survive every weird input.
- Modules. One Python file imports another.
- Datetime. Timestamp every note; calculate "days ago".
- Regex. Search notes by pattern.
The Spec
5 minThe app is called jot. Each note is a dict:
{ "id": 1, "text": "Pick up groceries", "tags": ["errand", "today"], "done": false, "created": "2026-05-27T14:32:00" }
Menu:
1. add — add a new note with #tags inline 2. list — show all notes (or with a tag filter) 3. done — mark a note done by id 4. search — find notes by regex 5. delete — remove a note by id 6. highlight — show one random unfinished note 7. export — write a Markdown file of all notes 8. quit — save and exit
Backing file: notes.json. Auto-loaded on start, auto-saved after every change.
Task 1 · The Storage Module
10 minSave the data layer as jot_storage.py:
# jot_storage.py — JSON-backed persistence import json FILE = "notes.json" def load(): try: with open(FILE, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return [] except json.JSONDecodeError: print("[!] notes.json is corrupt — starting fresh.") return [] def save(notes): with open(FILE, "w", encoding="utf-8") as f: json.dump(notes, f, indent=2, ensure_ascii=False) def next_id(notes): if not notes: return 1 return max(n["id"] for n in notes) + 1 if __name__ == "__main__": notes = load() print(f"Loaded {len(notes)} notes; next id would be {next_id(notes)}.")
Task 2 · The Note Parser
10 minUsers add notes by typing something like buy milk #errand #today. Extract the inline #tag tokens with regex.
# jot_parse.py — pull tags out of a free-text note import re TAG_PAT = re.compile(r"#(\w+)") def parse_note(text): tags = TAG_PAT.findall(text) clean_text = TAG_PAT.sub("", text).strip() clean_text = re.sub(r"\s+", " ", clean_text) return clean_text, sorted(set(t.lower() for t in tags)) if __name__ == "__main__": print(parse_note("buy milk #ERRAND #today")) # → ('buy milk', ['errand', 'today'])
Tags are extracted; the original text has them stripped out. A set + sort dedupes and orders alphabetically. re.sub twice — once to remove the tags, once to collapse double-spaces.
Task 3 · The Display
10 minFormat each note as a tidy one-liner with f-strings and width specifiers.
# jot_display.py — f-string formatting from datetime import datetime def format_note(n): mark = "✓" if n["done"] else " " when = datetime.fromisoformat(n["created"]) age = (datetime.now() - when).days tags = " ".join(f"#{t}" for t in n["tags"]) return f" [{n['id']:>3}] [{mark}] {n['text']:<40} ({age}d ago) {tags}" def show(notes, tag=None): filtered = notes if tag is None else [n for n in notes if tag in n["tags"]] if not filtered: print(" (no notes match)") return for n in filtered: print(format_note(n)) print(f" {len(filtered)} of {len(notes)} shown.")
Task 4 · The Main App
15 minWire it all together. Save as jot.py:
# jot.py — main CLI app import random import re from datetime import datetime from jot_storage import load, save, next_id from jot_parse import parse_note from jot_display import show, format_note def add(notes): raw = input("New note (use #tag to tag): ").strip() if not raw: print(" ! Empty.") return text, tags = parse_note(raw) notes.append({ "id": next_id(notes), "text": text, "tags": tags, "done": False, "created": datetime.now().isoformat(timespec="seconds"), }) save(notes) print(f" Added #{notes[-1]['id']}.") def mark_done(notes): try: i = int(input("Done #id: ")) except ValueError: print(" ! Numbers only.") return for n in notes: if n["id"] == i: n["done"] = True save(notes) print(f" Marked #{i} done.") return print(f" ! No note with id {i}.") def search(notes): raw = input("Regex pattern: ") try: pat = re.compile(raw, re.IGNORECASE) except re.error as e: print(f" ! Bad regex: {e}") return matches = [n for n in notes if pat.search(n["text"])] if not matches: print(" (no matches)") return for n in matches: print(format_note(n)) def delete(notes): try: i = int(input("Delete #id: ")) except ValueError: print(" ! Numbers only.") return for j, n in enumerate(notes): if n["id"] == i: confirm = input(f" Delete '{n['text']}'? (y/n) ").lower() if confirm == "y": del notes[j] save(notes) print(f" Removed #{i}.") return print(f" ! No note with id {i}.") def highlight(notes): open_notes = [n for n in notes if not n["done"]] if not open_notes: print(" No open notes — congrats!") return n = random.choice(open_notes) print("\n--- TODAY'S HIGHLIGHT ---") print(format_note(n)) print("-------------------------") def export(notes): path = "notes.md" with open(path, "w", encoding="utf-8") as f: f.write("# My Notes\n\n") for n in sorted(notes, key=lambda x: x["created"]): mark = "x" if n["done"] else " " tags = " ".join(f"#{t}" for t in n["tags"]) f.write(f"- [{mark}] {n['text']} {tags}\n") print(f" Wrote {path}.") def main(): notes = load() print(f"jot · {len(notes)} note(s) loaded") while True: print() print("1 add 2 list 3 done 4 search 5 delete 6 highlight 7 export 8 quit") pick = input("> ").strip() try: if pick == "1": add(notes) elif pick == "2": tag = input(" Filter tag (or blank): ").strip().lower() or None show(notes, tag) elif pick == "3": mark_done(notes) elif pick == "4": search(notes) elif pick == "5": delete(notes) elif pick == "6": highlight(notes) elif pick == "7": export(notes) elif pick == "8": print("Bye!") return else: print("Pick 1-8.") except Exception as e: print(f" [unexpected error] {e}") try: main() except KeyboardInterrupt: print("\nBye!")
Test Drive
8 minPlay with it. Add at least 10 notes across a few tags. Test every menu option. Confirm the round-trip works:
- Add 3 notes. Quit. Restart. They're still there.
- Mark one done. Search for a word that appears in it. The match shows the tick.
- Export to Markdown. Open
notes.mdin VS Code — looks like a real list. - Try a bad regex like
[unclosed. The program prints "Bad regex" instead of crashing. - Press Ctrl+C mid-prompt. Exits cleanly with "Bye!"
If all five pass, you've shipped Level 2.
Level 2 Recap · The Headline Ideas
5 minForty-eight lessons. Here's the journey in one picture.
Collections Lists deep dive · Comprehensions teaser · Tuples · Unpacking Dicts · .keys/.values/.items · Sets · Set operations Lists-of-dicts · Dicts-of-lists · Inventory Inspector Strings & Formatting split/join/strip/find · f-strings · format specifiers · Mad Libs 2.0 Random randint, choice, choices, shuffle, sample, seed · Higher-or-Lower Files read · write · High-Score Keeper · Wordle-Lite trilogy Errors try/except · multiple except · finally/else · Error-Proof Calculator Modules stdlib import · own modules · datetime Turtle Drawing · loops · colours · Race · Maze · Mandala Tic-Tac-Toe Board · win detection · easy AI Regex findall/search · character classes · validators JSON dump/load · Quiz engine Capstone jot — a real CLI app you'd use
That's a whole second toolkit on top of Level 1. You can build real apps now — apps that survive being closed, share their data, handle weird input gracefully, and offer a polished UI to the user.
Level 3 introduces Object-Oriented Programming. You'll build classes, write your own data types, and meet inheritance — the way every big software project is structured. You'll also meet generators, recursion, and classic algorithms. By the end of Level 3 you'll be ready for the PCEP exam.
Capstone Homework
4 minExtend jot with two of these features. Hand in the upgraded code:
- Edit. Add a menu option
9 editthat lets you change a note's text by id. - Priority. Add a
priorityfield (1-5). Show notes sorted by priority desc, then by date. - Due dates. Add a
duefield (ISO date). Show "overdue", "due today", "due in N days". - Import. Read a Markdown file of
- [ ] some tasklines and add each as a new note. - Backup. Before every save, copy
notes.jsontonotes.json.bak.
Be proud of what you've made. Hand jot.py and friends in — this is your Level-2 ticket.
Sample · priority feature
# Add to parse_note: PRIORITY_PAT = re.compile(r"!([1-5])") def parse_note(text): tags = TAG_PAT.findall(text) pri = PRIORITY_PAT.search(text) priority = int(pri.group(1)) if pri else 3 # default 3 clean = TAG_PAT.sub("", PRIORITY_PAT.sub("", text)).strip() clean = re.sub(r"\s+", " ", clean) return clean, sorted(set(t.lower() for t in tags)), priority # Usage: "buy milk !1 #errand" → text "buy milk", tags ["errand"], priority 1 # When adding: notes.append({..., "priority": priority}) # When listing, sort: filtered.sort(key=lambda n: (-n["priority"], n["created"]))
Non-negotiables: regex for priority extraction, a default value when not specified, and a sort that respects both priority and date. Negative-sign trick for descending sort.
Well Done
3 minForty-eight lessons. Hours of typing, plenty of crashes, dozens of small wins. You started Level 2 able to print things. You finish it able to build a real CLI app — one that survives being closed, persists its data, handles weird input, and could plausibly become a real tool in your daily life.
Level 3 is waiting whenever you're ready. New shapes — classes, generators, recursion, algorithms. Same goal: getting better at telling a computer exactly what you want.
See you in Level 3.