Ship Your First Python App

Ship Your First Python App course cover

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 built this 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.

Week 1 is free. The full course is €19 including VAT, one-time: all six weeks on the course platform, a new week every 2 to 7 days at the pace you choose, each to download as Markdown or EPUB or print. Plus a reference solution, a certificate when you finish, and my Claude Code setup.

Get the full course · €19 →

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). Each week's lessons give you the tests to copy into your project, and the full course includes a reference solution for every week, which unlocks after your last week.

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
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(tmp_path):
    path = tmp_path / "journal.json"
    path.touch()
    return path


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.

2 Week 2 · in the full courseCLI & Type Safety Unlock

Wrap it in a real command-line interface using the Typer library, add type hints, and handle errors so users get a clean message instead of a traceback.

3 Week 3 · in the full courseFeatures & Search Unlock

Case-insensitive search, tag filtering, and limiting to only see the most recent entries.

4 Week 4 · in the full courseCode Organization & Testing Unlock

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

5 Week 5 · in the full courseTest Coverage & Polish Unlock

Show entries in a Rich table, and measure coverage with pytest-cov to find untested code.

6 Week 6 · in the full courseShip It Unlock

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 Full-Stack Python you build Snipster: one clean core behind a CLI, a REST API and a web UI, with the repository pattern and a cloud deploy. Self-paced, €59.

See Full-Stack Python →