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 .gitignorePrefer 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.tomlis what you ask for:typer>=0.12. It's short, readable, and you may edit it by hand.uv.lockis what you actually got:typer==0.12.3plus 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.
- Install VS Code, then install the Python extension from Microsoft (Extensions panel, search "Python").
- Open your project folder: File > Open Folder, pick
dev-journal. - 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
.venvfolder (the one uv created). Now VS Code uses the same environment asuv run, so imports resolve and there are no "module not found" surprises. - 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
printstatements 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), nottags: 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), anddefault_factorygives 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_entrypins the defaults: a new entry starts with an empty tag list and an auto-filled date (we assertnot Nonebecause the exact timestamp is unpredictable).test_each_entry_gets_own_tag_listconfirmsdefault_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_longcheck that__post_init__raises aValueErroron invalid inputs.test_save_and_load_entriesis the JSON round-trip: what you save to JSON is what you load back.test_load_entries_nonexistent_fileconfirms the empty-file default: a missing file returns[], it does not raise an exception or crash the program.test_save_entries_creates_valid_jsonopens the saved file with plainjson.load, proving it is real JSON (a list of dicts) that any other tool could read.test_add_multiple_entriesprovesadd_entryappends 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 mainWeek 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?
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
--helpfor 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 week2Typer: 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)thenhello Alice). - Options are named flags (
name: str = typer.Option("Alice")then--name Bob). The first value you pass totyper.Optionis 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")showsTitle:and waits for input.@app.command("list")registers a command under a name. Name the functionlist_entries, notlist, 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 charactersReading 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_flagspasses 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_titlesends an empty title.__post_init__raises, yourexceptturns it intotyper.BadParameter, and the exit code is non-zero. If this test shows a traceback instead, thetry/exceptis missing.test_cli_list_entriesadds two entries with your Week 1add_entry, then checkslistshows both titles.test_cli_list_emptychecks 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 anymorePrefer 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
--tagfilter onlist - A
--limitflag to show the last N entries - A
searchcommand 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 week3Case-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:
- A
matches_query(entry: JournalEntry, query: str) -> boolfunction that returnsTruewhen the query (case-insensitive) is in the entry's title, content, or any of its tags. - The
--tagand--limitoptions onlist, using the signature above. Filter by tag first, then apply the limit. - A
searchcommand, using the signature above, that keeps the entries wherematches_queryisTrueand 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_querytests 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 singleJournalEntrywithout touching a file. test_matches_query_content_and_tagschecks all three places a match can come from."test"is in neither the title nor the content, only in thetestingtag, and it is a partial match.test_cli_search_no_resultsexpects exit code0: finding nothing is a normal outcome, not an error.test_cli_list_with_limitadds 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?
Split one file into modules, making a proper package, group persistence into a class, and share test setup with pytest fixtures.
Use the Rich library to nicely format an output table, and measure coverage with pytest-cov to find untested code.
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 →