Ship Your First Python App

If you're learning Python, you've probably noticed that the syntax isn't the hard part. It's everything around it that can be overwhelming: the terminal, Git, virtual environments, dependencies, packaging, setting up an SSH key to push to GitHub, and writing your first tests.

And those things are rarely taught together in one place. That's why I'm building this free, self-paced course: to help you not only write Python, but learn the workflow around it and ship a complete application.

Who it's for: you can write basic Python (functions, loops, installing a package, maybe some notebooks) and you may have used a class, but you haven't built a project from scratch with Git, tests, and proper tooling yet.

In this course you'll build a command-line developer journal against a provided test suite: each week you're handed failing tests that spell out what the code should do, and you write the code to make them pass. It's a real Python project built with the modern workflow: uv, ruff, typer, pytest, and the proper git + GitHub flow. By the end you'll have a project on GitHub that shows you have these basics down, and a starting point for your own ideas.

Get the starter code on GitHub →

The app grows one week at a time, each week building on the last:

flowchart LR
  W1["Week 1: Setup"] --> W2["Week 2: CLI"]
  W2 --> W3["Week 3: Search"]
  W3 --> W4["Week 4: Package"]
  W4 --> W5["Week 5: Coverage"]
  W5 --> W6["Week 6: Ship"]

How it works: each week is a set of short lessons and one build exercise. You get the tests and you write the code that makes them pass. Finish the quick check at the end of a week to test your knowledge and complete the week. Progress is saved in this browser, so you can come back whenever you have time.

Where you build: in your own dev-journal repo, which you create in Week 1. It grows week by week, one pull request per week, and ends up as a project on your GitHub (new to repos and branches? Week 1 walks you through it). The starter repo has a folder per week with that week's tests and stubbed code. Copy what the lesson tells you to into your project, but never copy its main.py over yours: your own code carries forward. Truly stuck? The solution branch with the finished code for every week goes live after Week 6.

Week 1 hands you most of the code, so you can focus on getting the workflow running. Starting week 2 I'll ask you to write more code, until you are building from the tests alone. From following along to working on your own.

uv for the win: This course uses uv (related blog article) for everything: installing Python, virtual environments, packages, and running code. No pip, no manual venv, no pyenv. uv makes this all a lot simpler, and I am sure that, like me, you don't want to look back once you're used to it.

What you're building

Over six weeks you build Dev Journal (the dev-journal repo, run as journal), a command-line tool to log what you learn as a developer. By Week 6 it works like this:

This week lays the foundation: the tooling, plus the code that defines a journal entry and saves it to a file. No CLI yet, that comes in Week 2.

What you'll learn this week

  • Get comfortable in the terminal
  • Set up Git and open your first pull request on GitHub (including SSH keys)
  • Start a Python project with uv
  • Set up VS Code: pick the right interpreter, run and debug your code (you can also use Vim, which is my favorite editor, in that case I recommend you watch my Supercharge Your Vim Workflow: Essential Tips and Plugins for Efficiency to get started)
  • Model data with a dataclass and save it to disk as JSON

By the end you will have a project on GitHub with a validated data model, a persistence layer, and a passing test suite. Not bad for starters :)

Getting comfortable with the terminal

A terminal is a text-based way to talk to your computer. Instead of clicking, you type a command and press Enter.

  • macOS: open Terminal.app (search "Terminal" in Spotlight)
  • Windows: open PowerShell or Windows Terminal
  • Linux: open your terminal emulator (often Ctrl+Alt+T)

You only need a handful of commands to start:

pwd                     # where am I? (print working directory)
ls                      # list files here
cd dev-journal          # move into a folder
cd ..                   # go up one level
mkdir my-folder         # make a new folder

These work the same in macOS / Linux terminals and in Windows PowerShell. Start typing a filename and press Tab to auto-complete it. If you ever feel lost, pwd tells you where you are and ls tells you what's in a directory. You will pick the rest up as you go (or send me a question, I really love Unix!)

Git, GitHub and your first pull request

Git is version control software, it tracks changes to your files on your computer. GitHub hosts those files online so you can share them and build a portfolio. Think of Git as tracking changes and GitHub as the shared drive.

Set up SSH keys

SSH keys let you communicate securely with a remote server (GitHub) without typing a password every time. You generate a key pair: a private key you keep on your computer, and a public key you give to GitHub. When you push code, your computer proves to GitHub that it has the private key without ever sending it.

Run these commands in your terminal, one at a time:

# macOS / Linux
ssh-keygen -t ed25519 -C "[email protected]"   # press Enter for the default path
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub                     # copy this output
# Windows (PowerShell, run as Administrator for the ssh-agent lines)
ssh-keygen -t ed25519 -C "[email protected]"   # press Enter for the default path
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
ssh-add $env:USERPROFILE\.ssh\id_ed25519
Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub   # copy this output

On Windows you can also use Git Bash (installed with Git for Windows), where the macOS / Linux commands work as is.

On GitHub go to Settings > SSH and GPG keys > New SSH key and paste the public key. Test it:

ssh -T [email protected]

The weekly loop

You repeat this same cycle every week of the course:

flowchart LR
  A["git checkout -b weekN"] --> B["write code, make tests pass"]
  B --> C["commit and push"]
  C --> D["open pull request"]
  D --> E["review your own diff"]
  E --> F["merge into main"]
  F --> G["delete branch"]
  G -->|next week| A

Create a repository on GitHub (name it dev-journal, initialize it with a README and a Python .gitignore), then:

git clone [email protected]:YOUR_USERNAME/dev-journal.git
cd dev-journal

The .gitignore decides what stays out of Git. The Python template GitHub gave you is the standard Python.gitignore: it ignores the virtual environment (.venv), __pycache__, tool caches, and .env files with secrets. If you skipped that checkbox, download it into your repo now, before your first commit:

# macOS / Linux
curl -s https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore > .gitignore
# Windows (PowerShell)
Invoke-WebRequest https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore -OutFile .gitignore

Prefer to skip the terminal? Open the raw Python.gitignore, select all and copy it. In VS Code, create a new file named .gitignore (with the leading dot, no extension) in the root of your dev-journal folder, paste, and save.

Anything else that is not code, like your app's data files, you add to .gitignore yourself (you will do this for your journal data in Week 2). Then work on a branch and commit:

git checkout -b week1          # work on a branch, never straight on main
# ... make changes ...
git add -A                     # stage everything that changed (minus what .gitignore excludes)
git status                     # check what you are about to commit
git commit -m "feat: add JournalEntry dataclass"
git push origin week1

Glance at git status before every commit: if a file shows up that should not be shared, add it to .gitignore before you commit.

Then on GitHub, open a pull request from week1 into main. A pull request is how you say "this work is ready, review (and approve) it before merging."

Even working solo, open the PR: it is a checkpoint in this repo's history. And it's how professional dev teams work. You can review your own code diff under "Files changed". Then use the same page to merge it (big green button) into main and delete the branch. That deletes it on GitHub only, so clean up your local copy too:

git checkout main      # switch back to main
git pull               # pull in the merged work
git branch -d week1    # delete the local branch (-d refuses if it isn't merged yet)

Next week, we'll start a new branch from main and repeat this process.

git status is your most-used command. Run it whenever you are unsure what is going on; it tells you what changed and what to do next (bonus: I have a shell alias gs for it because I use it many times each day).

Start the project with uv

Install uv:

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
uv --version   # open a new terminal first if this isn't found

From inside your cloned repo:

uv init --no-package

This creates pyproject.toml (project metadata and dependencies), a .python-version pin, and a starter main.py. The first uv add or uv run adds uv.lock (exact pinned versions, commit this).

Note the --no-package flag. Recent uv versions set up an installable package by default (a src/ folder, a build system, an entry point), the kind of project you would upload to PyPI for others to install. For real projects that default is what you want. We skip it here only for simplicity: a single main.py lets you focus on the workflow first. In Week 4, once the journal outgrows one file, you turn it into a package by hand, so you learn what each part of that default setup does.

The uv commands you will use throughout the course (no need to run them now, each lesson tells you when):

uv run main.py          # run code in the right environment, no activation needed
uv add typer            # runtime dependency: your app needs it to run (Week 2)
uv add --dev pytest     # dev dependency: only you need it while developing (end of this week)
uv sync                 # install everything from pyproject.toml (after cloning)

You never create or activate a virtual environment by hand. That's the beauty of uv, its commands (run and sync) cascade all needed operations: it creates a virtual environment if needed, installs dependencies if not already done so, and it runs your code automatically inside the virtual environment. It takes a lot of thinking out, or it's ergonomic as we developers like to say.

flowchart TD
  A["uv run main.py"] --> B{"venv exists?"}
  B -->|no| C["create .venv"]
  B -->|yes| D{"installed packages match uv.lock?"}
  C --> D
  D -->|no| E["install from uv.lock"]
  D -->|yes| F["run your code"]
  E --> F

These two files are easy to mix up:

  • pyproject.toml is what you ask for: typer>=0.12. It's short, readable, and you may edit it by hand.
  • uv.lock is what you actually got: typer==0.12.3 plus every package typer itself depends on, each pinned to an exact version. uv writes it; you never edit it.

When you run uv add, uv updates both: it adds the dependency to pyproject.toml and records the exact resolved versions in uv.lock. When you run uv sync, it reads the lock file and installs. This lock file needs to be committed to your repository so that anyone else who clones your repo can run uv sync and get the exact same environment. That matters on a team and when you deploy: everyone runs the exact same versions.

Write and run code in VS Code

You can use any editor, but if you are starting out, VS Code is the one to pick: it is free, and its Python support handles the parts that trip people up.

  1. Install VS Code, then install the Python extension from Microsoft (Extensions panel, search "Python").
  2. Open your project folder: File > Open Folder, pick dev-journal.
  3. Select the interpreter. This is the step where people tend to get stuck. Open the command palette (Cmd/Ctrl+Shift+P), run Python: Select Interpreter, and choose the one inside your project's .venv folder (the one uv created). Now VS Code uses the same environment as uv run, so imports resolve and there are no "module not found" surprises.
  4. Run and debug. Open a Python file and press F5 to run it under the debugger, or click in the gutter to the left of a line number to set a breakpoint, then F5. Execution pauses there so you can inspect variables. This beats scattering print statements once you get used to it.

You will still run tests and git from the integrated terminal (Terminal > New Terminal), which opens right inside your project.

Model the data with a dataclass

A dataclass is Python's built-in way to write a class that mostly holds data. Compare a regular class with the dataclass version:

from dataclasses import dataclass


class Point:
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

Point(1, 2)                 # <__main__.Point object at 0x104f2a5d0>
Point(1, 2) == Point(1, 2)  # False, compares identity, not values


@dataclass
class Point:
    x: int
    y: int

Point(1, 2)                 # Point(x=1, y=2)
Point(1, 2) == Point(1, 2)  # True

The @dataclass decorator writes three "dunder" (double underscore) methods for you: __init__ builds the object from the arguments you pass, __repr__ gives it a readable printout, and __eq__ makes == compare the values. Less code to write, and fewer places for bugs.

Let's define a dataclass to hold our journal entries. Each entry has a title, content, tags, and a date.

from dataclasses import dataclass, field
from datetime import datetime

MAX_TITLE_LENGTH = 50
MAX_CONTENT_LENGTH = 1000


@dataclass
class JournalEntry:
    title: str
    content: str
    tags: list[str] = field(default_factory=list)
    date: str = field(default_factory=lambda: datetime.now().isoformat())

    def __post_init__(self):
        if not self.title or len(self.title) > MAX_TITLE_LENGTH:
            raise ValueError(f"Title must be non-empty and <= {MAX_TITLE_LENGTH} characters")
        if not self.content or len(self.content) > MAX_CONTENT_LENGTH:
            raise ValueError(f"Content must be non-empty and <= {MAX_CONTENT_LENGTH} characters")

A couple of things here:

  • field(default_factory=list), not tags: list[str] = []. A plain [] default would be one list shared by every instance, so a tag added to one entry would appear on all of them. Dataclasses refuse it outright (ValueError: mutable default ... use default_factory), and default_factory gives each instance its own fresh list. This is a common mutability bug in Python (see here).

  • __post_init__ runs right after the object is built. Validating here means the model enforces its own rules: the title and content should not be empty, and they should not exceed certain lengths. This is a nice object oriented design principle: the object is responsible for its own integrity.

  • Given we use the length variables twice, we define them as constants at the top of the file. This is the DRY principle: Don't Repeat Yourself. If we ever want to change the maximum lengths, we only need to change them in one place.

Save and load with JSON

JSON is a plain-text format for structured data. Like a .txt file you can open it in any editor, but it has a fixed structure (keys, values, lists), so Python can read it straight back into dicts and lists without you having to parse lines yourself. This is what your journal file will look like:

[
  {
    "title": "Learned uv",
    "content": "One tool for everything",
    "tags": ["python", "tooling"],
    "date": "2026-09-29T10:15:42.123456"
  }
]

json.dump can write dicts and lists but not your dataclass directly, so we use asdict to convert it to a dict first:

>>> asdict(JournalEntry(title="Learned uv", content="One tool", tags=["python"]))
{'title': 'Learned uv', 'content': 'One tool', 'tags': ['python'], 'date': '2026-09-29T10:15:42.123456'}

When loading, we read the JSON into a list of dicts and then unpack each dict into a JournalEntry with JournalEntry(**entry).

import json
from dataclasses import asdict
from pathlib import Path


def save_entries(entries: list[JournalEntry], db_path: Path) -> None:
    data = [asdict(entry) for entry in entries]
    with open(db_path, "w") as f:
        json.dump(data, f, indent=2)


def load_entries(db_path: Path) -> list[JournalEntry]:
    if not db_path.exists() or db_path.stat().st_size == 0:
        return []
    with open(db_path) as f:
        data = json.load(f)
    return [JournalEntry(**entry) for entry in data]

JournalEntry(**entry) might look magical to you, it unpacks a dictionary into keyword arguments and it's the reverse of asdict.

We also check if the file exists and is non-empty, because json.load raises an error on an empty file. You can also raise an exception if the file is missing, but for this app we want to start with an empty journal, so an empty list is a valid state.

Keep the code clean with ruff

ruff (related blog article) is a fast formatter and linter from the same Astral team as uv. Add it and run it before every commit:

uv add --dev ruff           # --dev: a tool for you, not something users of your app need
uv run ruff format .        # auto-format
uv run ruff check --fix .   # lint and auto-fix

Recent ruff versions turn on a lot of rules by default, some of them strict about things this course keeps simple on purpose. Pick the rules yourself by adding this to pyproject.toml:

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]

E4, E7, and E9 catch real errors (bad imports, invalid syntax), F flags mistakes like unused imports and undefined names, and I keeps your imports sorted. A small, explicit rule set means every warning you see is worth fixing. Once you're comfortable, browse the ruff rules and add more.

Bonus: to not do this manually each time, I recommend you set up a pre-commit hook for this. Check out pre-commit.com or Prek, which is a reimagined version of pre-commit, built in Rust.

It will run ruff automatically before every commit, so you never have to think about it. It adds a little overhead before each commit, but it keeps the codebase consistent and prevents review comments about formatting and easy-to-prevent bugs.

Your turn

One last piece ties the model and persistence together, add_entry: load what's there, append the new entry, save it back.

def add_entry(title: str, content: str, tags: list[str], db_path: Path) -> None:
    entries = load_entries(db_path)          # load existing first
    entry = JournalEntry(title=title, content=content, tags=tags)
    entries.append(entry)                    # append, don't overwrite
    save_entries(entries, db_path)

Now it's your turn: assemble main.py from the pieces above, put this test suite in test_main.py, add pytest as a dev dependency (uv add --dev pytest), and run uv run pytest -v until it passes (you'll see green dots in your terminal). If not, read the error messages and fix the code until they do.

import json
import tempfile
from pathlib import Path

import pytest

from main import (
    MAX_CONTENT_LENGTH,
    MAX_TITLE_LENGTH,
    JournalEntry,
    add_entry,
    load_entries,
    save_entries,
)


@pytest.fixture
def db_file():
    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
        yield Path(f.name)


def test_create_valid_entry():
    entry = JournalEntry(title="Test Title", content="Test content")
    assert entry.title == "Test Title"
    assert entry.tags == []
    assert entry.date is not None


def test_each_entry_gets_own_tag_list():
    entry1 = JournalEntry(title="First", content="Content")
    entry2 = JournalEntry(title="Second", content="Content")
    entry1.tags.append("python")
    assert entry2.tags == []


def test_empty_title():
    with pytest.raises(ValueError):
        JournalEntry(title="", content="Valid")


def test_title_too_long():
    with pytest.raises(ValueError):
        JournalEntry(title="T" * (MAX_TITLE_LENGTH + 1), content="Valid")


def test_content_too_long():
    with pytest.raises(ValueError):
        JournalEntry(title="Valid", content="C" * (MAX_CONTENT_LENGTH + 1))


def test_save_and_load_entries(db_file):
    entries = [
        JournalEntry(title="First", content="Content 1", tags=["tag1"]),
        JournalEntry(title="Second", content="Content 2", tags=["tag2"]),
    ]
    save_entries(entries, db_file)
    loaded = load_entries(db_file)
    assert len(loaded) == 2
    assert loaded[0].tags == ["tag1"]
    assert loaded[1].tags == ["tag2"]


def test_load_entries_nonexistent_file():
    assert load_entries(Path("nonexistent.json")) == []


def test_save_entries_creates_valid_json(db_file):
    save_entries([JournalEntry(title="Test", content="Content")], db_file)
    with open(db_file) as f:
        data = json.load(f)
    assert isinstance(data, list)
    assert data[0]["title"] == "Test"


def test_add_multiple_entries(db_file):
    add_entry("First", "Content 1", ["tag1"], db_file)
    add_entry("Second", "Content 2", ["tag2"], db_file)
    entries = load_entries(db_file)
    assert len(entries) == 2
    assert entries[0].title == "First"
    assert entries[1].title == "Second"

Read the tests as a spec, they tell you exactly what the code must do:

  • test_create_valid_entry pins the defaults: a new entry starts with an empty tag list and an auto-filled date (we assert not None because the exact timestamp is unpredictable).
  • test_each_entry_gets_own_tag_list confirms default_factory ("give each instance its own fresh list"), adding a tag to one entry should not touch another.
  • test_empty_title / test_title_too_long / test_content_too_long check that __post_init__ raises a ValueError on invalid inputs.
  • test_save_and_load_entries is the JSON round-trip: what you save to JSON is what you load back.
  • test_load_entries_nonexistent_file confirms the empty-file default: a missing file returns [], it does not raise an exception or crash the program.
  • test_save_entries_creates_valid_json opens the saved file with plain json.load, proving it is real JSON (a list of dicts) that any other tool could read.
  • test_add_multiple_entries proves add_entry appends and does not overwrite any existing entry.

When the tests pass, run the full loop, then commit and open your PR:

# testing
uv run pytest -v
# linting
# manual run, if pre-commit is set up this gets run in the `git commit` step
uv run ruff format .
uv run ruff check --fix .
# commit your work and push it to GitHub
git add -A
git status   # main.py, test_main.py, pyproject.toml, uv.lock, .python-version
git commit -m "feat: add JournalEntry dataclass with JSON persistence"
git push origin week1
# git should show a direct link here to open a pull request on GitHub
# if not, go to your repo on GitHub and open a PR from week1 into main

Week 1 check

Answer all three to complete Week 1.

Why does JournalEntry use field(default_factory=list) for tags?

What does uv run main.py do for you?

Why does load_entries check for a missing or empty file first?

Week 1 done. Subscribe to get a heads-up when the next week drops, plus emails on Python, Rust, and AI.

What you'll learn this week

Welcome back! Right now, using your journal means opening a Python REPL (or notebook) and calling functions by hand. This week you wrap it in a real command-line interface using Typer, so anyone can run it from the command line:

uv run main.py add --title "My entry" --content "Today I learned Typer"
uv run main.py list
  • Build CLI commands with Typer (arguments, options, and --help for free)
  • Use type hints to describe what flows through your functions (Typer builds the CLI from them)
  • Handle errors so the user sees a clean message, not a traceback
  • Read a traceback when something does break

Your Week 1 add_entry and load_entries stay exactly as they are for now. The CLI is a thin layer on top. Keep working in your dev-journal repo, on a fresh branch:

git checkout main
git pull
git branch -d week1      # if you didn't clean it up last week
git checkout -b week2

Typer: type hints become your CLI

Type hints

You met type hints on the Week 1 dataclass. A type hint is a label saying what type a value should be. You write : type after a parameter and -> type after the parameter list for the return value:

def greet(name: str, times: int = 1) -> str:
    return f"Hello, {name}! " * times


def add(title: str, content: str, tags: list[str], db_path: Path) -> None:
    ...                            # -> None means it returns nothing


title: str | None = None           # str or None (Python 3.10+)
scores: dict[str, int] = {}        # hints work on variables too

Python does not enforce them at runtime. greet(42) still runs. A type checker does catch it, before you run anything. Optional but worth trying: ty, a fast type checker from the Astral team. Add it with uv add --dev ty and run uv run ty check . (or run it before every commit with pre-commit or Prek, as in Week 1).

The hints are also stored on the function, so tools can read them:

>>> greet.__annotations__
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}

That is the whole trick behind Typer. It reads these annotations to decide that times should be converted to an int (and rejected if the user types abc), and that name is a required argument because it has no default. Get in the habit of adding hints to every function signature: they make intent obvious, power Typer, and let a type checker catch mistakes.

From function to command

Add Typer to your project (this also pulls in Rich, which you will use in Week 5):

uv add typer

Then try it in a scratch greet.py (your journal stays in main.py):

import typer

app = typer.Typer()


@app.command()
def hello(name: str):
    typer.echo(f"Hello, {name}!")


@app.command()
def bye(name: str):
    typer.echo(f"Bye, {name}!")


if __name__ == "__main__":
    app()

Three pieces make this a CLI: app = typer.Typer() creates the app, the @app.command() decorator (like @dataclass in Week 1, it wraps the function below it) registers each function as a command, and app() under if __name__ == "__main__": starts it when you run the file. typer.echo is Typer's print.

$ uv run greet.py hello World
Hello, World!
$ uv run greet.py --help
 Usage: greet.py [OPTIONS] COMMAND [ARGS]...
...
╭─ Commands ────────────────────────────────╮
│ hello                                     │
│ bye                                       │
╰───────────────────────────────────────────╯

Each decorated function becomes a subcommand, generated from the signatures, the same shape as the add and list commands you are about to build. Delete greet.py when you are done playing with it.

A few pieces you will use:

  • Arguments are positional (def hello(name: str) then hello Alice).
  • Options are named flags (name: str = typer.Option("Alice") then --name Bob). The first value you pass to typer.Option is the default, used when the flag is left out.
  • prompt= asks the user for a value when they do not pass the flag: typer.Option("", prompt="Title") shows Title: and waits for input.
  • @app.command("list") registers a command under a name. Name the function list_entries, not list, so you do not shadow the Python built-in.

Handle errors instead of crashing

Your __post_init__ raises ValueError for a bad entry. Without handling, a user typing an empty title gets a wall of traceback (trimmed here):

Traceback (most recent call last):
  ...
  File "main.py", line 61, in add
    add_entry(title, content, tag_list, db)
  File "main.py", line 48, in add_entry
    entry = JournalEntry(title=title, content=content, tags=tags)
  File "main.py", line 23, in __post_init__
    raise ValueError(
ValueError: Title must be non-empty and <= 50 characters

Reading a traceback

Read it from the bottom up. The last line is the actual error: its type (ValueError) and message. The lines above are the call stack, showing which function called which, with file names and line numbers: add called add_entry, which built a JournalEntry, whose __post_init__ raised. Go to the lowest line in your code first. Most errors are a typo, a missing import, or a wrong type, and the traceback points straight at them.

That is useful for you, the developer, but not for someone using your CLI. Catch the specific exception and turn it into a clean CLI error:

try:
    add_entry(title, content, tag_list, db)
except ValueError as e:
    raise typer.BadParameter(str(e)) from e

Now the user sees this instead:

Usage: main.py add [OPTIONS]
Try 'main.py add --help' for help.
╭─ Error ───────────────────────────────────────────────────────╮
│ Invalid value: Title must be non-empty and <= 50 characters   │
╰───────────────────────────────────────────────────────────────╯

Catch the narrowest exception you can (except ValueError, never a bare except), so real bugs still surface as tracebacks instead of being swallowed. from e links the new error to the original one, so if you ever do need the full story while debugging, it is still there.

typer.BadParameter also makes the program exit with a non-zero exit code. Every program returns a number when it finishes: 0 means success, anything else means something went wrong. Scripts, CI pipelines, and (below) your tests check this number to know whether a command worked. Try it: run a command, then echo $? (macOS / Linux) or echo $LASTEXITCODE (PowerShell) to see its exit code.

Your turn

Add add and list commands to your main.py so the CLI works end to end. First the plumbing from greet.py: import typer at the top, app = typer.Typer() below your constants, and this at the very bottom of the file:

if __name__ == "__main__":
    app()

Then the two commands. Start from these signatures so the options match the tests; the bodies are yours:

@app.command()
def add(
    title: str = typer.Option("", prompt="Title", show_default=False),
    content: str = typer.Option("", prompt="Content", show_default=False),
    tags: str = typer.Option(
        "", prompt="Tags (comma separated)", show_default=False
    ),
    db: Path = typer.Option(Path("journal.json"), hidden=True),
) -> None:
    # turn "python, cli" into ["python", "cli"], save the entry,
    # echo "Entry saved.", and handle a ValueError as shown above
    ...


@app.command("list")
def list_entries(
    db: Path = typer.Option(Path("journal.json"), hidden=True),
) -> None:
    # load the entries; if there are none, echo a message containing "no",
    # otherwise echo each entry's title, date, content, and tags
    ...

The "" defaults mean: no flag given, so prompt for it. show_default=False keeps that empty default out of the prompt, so it reads Title: instead of Title []:. Tags are optional, so pressing Enter at the tags prompt gives you an empty string, which should become an empty list.

The --db option is hidden=True because it exists so the tests can point at a temporary file (to not overwrite your real journal!); users never need it.

Add these tests to your test_main.py (or copy week2/test_main.py from the starter repo, which has both weeks). Imports belong at the top of the file, so put from typer.testing import CliRunner with your other imports and add app to your existing from main import (...) list. The rest goes below your Week 1 tests:

from typer.testing import CliRunner

from main import app  # add to your existing `from main import (...)`


@pytest.fixture
def runner():
    return CliRunner()


def test_cli_add_with_flags(db_file, runner):
    result = runner.invoke(
        app,
        ["add", "--title", "CLI Entry", "--content", "Added via CLI",
         "--tags", "python, cli", "--db", str(db_file)],
    )
    assert result.exit_code == 0
    assert "Entry saved" in result.output


def test_cli_add_invalid_title(db_file, runner):
    result = runner.invoke(
        app,
        ["add", "--title", "", "--content", "Valid content",
         "--tags", "", "--db", str(db_file)],
    )
    assert result.exit_code != 0


def test_cli_list_entries(db_file, runner):
    add_entry("Entry One", "Content 1", ["tag1"], db_file)
    add_entry("Entry Two", "Content 2", ["tag2"], db_file)
    result = runner.invoke(app, ["list", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "Entry One" in result.output
    assert "Entry Two" in result.output


def test_cli_list_empty(db_file, runner):
    result = runner.invoke(app, ["list", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "no" in result.output.lower()

CliRunner runs your CLI inside the test, as if you had typed the command in a terminal. runner.invoke(app, [...]) takes the command and its flags as a list of strings, and gives back a result with the exit_code and everything the command printed in output.

Read the tests as a spec:

  • test_cli_add_with_flags passes every value as a flag, so no prompts appear. The command should succeed (exit code 0) and confirm with "Entry saved".
  • test_cli_add_invalid_title sends an empty title. __post_init__ raises, your except turns it into typer.BadParameter, and the exit code is non-zero. If this test shows a traceback instead, the try/except is missing.
  • test_cli_list_entries adds two entries with your Week 1 add_entry, then checks list shows both titles.
  • test_cli_list_empty checks that an empty journal gets a friendly message (containing "no"), not blank output or a crash.
Stuck? Reveal a hint

For add, split the tags with [t.strip() for t in tags.split(",") if t.strip()] if tags else [], then call add_entry inside the try/except from above.

For list, load_entries(db); if the list is empty, echo something like "No journal entries found." and return, otherwise loop and typer.echo each entry's title, date, content, and tags.

When the tests pass, try your CLI for real:

uv run main.py --help
uv run main.py add          # answers the prompts
uv run main.py add --title "Flags" --content "No prompts this time" --tags "cli"
uv run main.py list

This writes your entries to journal.json in the repo. That's your personal data, not code, so keep it out of Git before your next commit:

# macOS / Linux
echo "journal.json" >> .gitignore
git status               # journal.json should not be listed anymore
# Windows (PowerShell)
Add-Content .gitignore "journal.json"   # not >>, older PowerShell writes an encoding Git can't read
git status               # journal.json should not be listed anymore

Prefer to skip the terminal? Open .gitignore in VS Code, add journal.json on a new line at the end, and save. Then run git status to check it worked.

(Running it as uv run journal add, like the demo at the top of the course, comes in Week 4.)

Run the full loop, commit, and push, then open your Week 2 PR from week2 into main like last week:

uv run ruff format .
uv run ruff check --fix .
uv run pytest -v
git add -A
git status               # main.py, test_main.py, pyproject.toml, uv.lock, .gitignore
git commit -m "feat: add Typer CLI with add and list commands"
git push origin week2

Once merged, clean up as in Week 1 (git checkout main, git pull, git branch -d week2).

Week 2 check

Answer all three to complete Week 2.

In Typer, what do the type hints on a command function control?

How should the CLI handle a ValueError from an invalid entry?

When you read a traceback, where is the actual error?

What you'll learn this week

A journal you cannot search is a chore, not a tool. This week you add a search command, filter the list by tag, and limit it to the most recent entries. Same patterns as most search boxes: case-insensitive, partial matches.

uv run main.py search pytest
uv run main.py list --tag python --tag rust --limit 5
  • Case-insensitive search across title, content, and tags
  • A repeatable --tag filter on list
  • A --limit flag to show the last N entries
  • A search command with a positional argument

Last week I gave you the signatures and you wrote the bodies. This week the lessons show each technique on a small example, and you apply it to the journal yourself. Start a fresh branch:

git checkout main
git pull
git branch -d week2      # if you didn't clean it up last week
git checkout -b week3

Case-insensitive matching

in checks whether one string appears inside another, but it is case-sensitive:

>>> "py" in "Python"
False
>>> "py" in "Python".lower()
True

The whole trick is lowercasing both sides before comparing, so "Python" finds "python", and "test" finds "testing" (a partial match):

>>> query = "PYTEST"
>>> query.lower() in "I love pytest".lower()
True

Tags are a list, so you need to check each one. any() takes a sequence of True / False values and returns True as soon as one of them is True:

>>> tags = ["Python", "testing"]
>>> any("test" in tag.lower() for tag in tags)
True
>>> any("rust" in tag.lower() for tag in tags)
False

The part inside any(...) is a generator expression: a list comprehension without the square brackets. It produces the values one at a time, so any() can stop at the first match.

Once you can ask "does this entry match?", filtering a list is a comprehension with an if:

>>> words = ["Python", "Rust", "pytest"]
>>> [w for w in words if "py" in w.lower()]
['Python', 'pytest']

Tag filtering and limiting

A repeatable option accepts the same flag more than once: --tag python --tag rust. Annotate it as list[str] and Typer collects the values into a list for you:

@app.command("list")
def list_entries(
    tag: list[str] = typer.Option([], help="Filter by tag (repeatable)"),
    limit: int = typer.Option(0, help="Show last N entries (0 = all)"),
    db: Path = typer.Option(Path("journal.json"), hidden=True),
) -> None:
    ...

The [] default is safe here because Typer builds a new list on each call, unlike a plain function default (the mutability trap from Week 1). With no --tag flags, tag is an empty list, which is falsy, so if tag: tells you whether to filter at all. Multiple tags use OR logic: show entries that have any of them.

For --limit, slicing with a negative start counts from the end of the list:

>>> entries = ["First", "Second", "Third"]
>>> entries[-2:]
['Second', 'Third']

New entries are appended to the end of the file, so entries[-limit:] gives you the newest ones. 0 means "no limit", which is why you only slice when limit > 0 (entries[-0:] is the whole list anyway, but the check makes the intent obvious).

The search command

So far every value came in as an option (--title ...). An argument is positional instead: users type search pytest, not search --query pytest. Use an argument for the one thing a command is about, and options for the extras.

@app.command()
def search(
    query: str = typer.Argument(..., help="Search term"),
    db: Path = typer.Option(Path("journal.json"), hidden=True),
) -> None:
    ...

The ... (Python's Ellipsis) as the first value means "no default": the argument is required, and Typer shows an error if it is missing. The help= text shows up in --help:

$ uv run main.py search --help
 Usage: main.py search [OPTIONS] {query}
...
╭─ Arguments ─────────────────────────────────────╮
│ *    query      <str>  Search term [required]   │
╰─────────────────────────────────────────────────╯

Your turn

Add three things to your main.py:

  1. A matches_query(entry: JournalEntry, query: str) -> bool function that returns True when the query (case-insensitive) is in the entry's title, content, or any of its tags.
  2. The --tag and --limit options on list, using the signature above. Filter by tag first, then apply the limit.
  3. A search command, using the signature above, that keeps the entries where matches_query is True and shows them. When nothing matches, echo a message containing "no".

list and search now both print entries the same way. Move the printing code from last week's list into a small helper, _display(entry: JournalEntry) -> None, and call it from both. The leading underscore signals "internal helper, not a command".

Add these tests to your test_main.py (or copy week3/test_main.py from the starter repo), and add matches_query to your from main import (...) list:

def test_matches_query_title():
    entry = JournalEntry(title="Python Basics", content="Content", tags=["python"])
    assert matches_query(entry, "python")


def test_matches_query_case_insensitive():
    entry = JournalEntry(title="PYTHON Basics", content="Content", tags=[])
    assert matches_query(entry, "python")


def test_matches_query_content_and_tags():
    entry = JournalEntry(title="Entry", content="I love pytest", tags=["testing"])
    assert matches_query(entry, "pytest")
    assert matches_query(entry, "test")  # matches the tag
    assert not matches_query(entry, "nonexistent")


def test_cli_search(db_file, runner):
    add_entry("Python Tips", "Use list comprehensions", ["python"], db_file)
    add_entry("Rust Guide", "Ownership model", ["rust"], db_file)
    result = runner.invoke(app, ["search", "python", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "Python Tips" in result.output
    assert "Rust Guide" not in result.output


def test_cli_search_no_results(db_file, runner):
    add_entry("Entry", "Content", ["tag"], db_file)
    result = runner.invoke(app, ["search", "nope", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "no" in result.output.lower()


def test_cli_list_with_tag(db_file, runner):
    add_entry("Python Entry", "Content", ["python"], db_file)
    add_entry("Rust Entry", "Content", ["rust"], db_file)
    result = runner.invoke(app, ["list", "--tag", "python", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "Python Entry" in result.output
    assert "Rust Entry" not in result.output


def test_cli_list_with_limit(db_file, runner):
    for title in ("First", "Second", "Third"):
        add_entry(title, "Content", [], db_file)
    result = runner.invoke(app, ["list", "--limit", "2", "--db", str(db_file)])
    assert result.exit_code == 0
    assert "Third" in result.output
    assert "Second" in result.output
    assert "First" not in result.output

Read the tests as a spec:

  • The matches_query tests call your function directly, no CLI involved. That is why it is worth pulling the matching logic into its own function: you can test it on a single JournalEntry without touching a file.
  • test_matches_query_content_and_tags checks all three places a match can come from. "test" is in neither the title nor the content, only in the testing tag, and it is a partial match.
  • test_cli_search_no_results expects exit code 0: finding nothing is a normal outcome, not an error.
  • test_cli_list_with_limit adds three entries in order and expects only the last two.
Stuck? Reveal a hint

For matches_query, lowercase the query once (q = query.lower()), then return q in entry.title.lower() or q in entry.content.lower() or any(...) with the any() pattern from above.

For the tag filter, lowercase the wanted tags first: wanted = [t.lower() for t in tag]. Then keep an entry if any of its tags, lowercased, is in wanted: [e for e in entries if any(et.lower() in wanted for et in e.tags)]. Note this is an exact tag match, not a partial one like search.

For search, results = [e for e in load_entries(db) if matches_query(e, query)], then echo the "no" message and return if it is empty, otherwise _display each one.

When the tests pass, try it on your real journal:

uv run main.py search uv
uv run main.py list --tag python
uv run main.py list --limit 1
uv run main.py search --help

Run the full loop, commit, and push, then open your Week 3 PR from week3 into main:

uv run ruff format .
uv run ruff check --fix .
uv run pytest -v
git add -A
git status
git commit -m "feat: add search, tag filter, and limit"
git push origin week3

Once merged, clean up as before (git checkout main, git pull, git branch -d week3).

Week 3 check

Answer all three to complete Week 3.

How do you make search case-insensitive?

What does any(q in tag.lower() for tag in entry.tags) do?

Why is query a typer.Argument rather than a typer.Option?

4 Week 4 · arrives Oct 9Code Organization & Testing Soon

Split one file into modules, making a proper package, group persistence into a class, and share test setup with pytest fixtures.

5 Week 5 · arrives Oct 16Test Coverage & Polish Soon

Use the Rich library to nicely format an output table, and measure coverage with pytest-cov to find untested code.

6 Week 6 · arrives Oct 23Ship It Soon

Write a README, do the final verification pass, merge, and optionally publish the package to PyPI or add a (web) frontend.

You finished Python Foundations 🎉

You set up a project, built a tested CLI, and shipped it. That is real engineering, not a tutorial you followed.

Ready to go from a CLI to a full-stack, deployed app? In the full-stack Python program you build one with me, one PR at a time: databases, an API, a web UI, and cloud deployment. Pick the Snipster project, or a Django SaaS.

See the full-stack program →