Projects, packaging and CI
Lay out a real project, pin its dependencies and test it on every push.
Create a free account Try part of it here
Why it matters
A script on your laptop and a project a team can trust are different things. The difference is mostly structure: a standard layout, pinned dependencies, automatic checks and a pipeline that runs the tests on every change. These are the first things a new job will expect you to know. They're also what makes AI coding agents safe to use, because the same checks catch the agent's mistakes.
What you'll be able to do
- Lay out a Python project the way professional teams do, with pyproject.toml
- Read version numbers and version ranges, and explain why lockfiles exist
- Set up linting, formatting and type checking
- Read a CI workflow that tests every push, and explain what it protects
What it covers
- A standard layout
- Versions and version ranges
- Automatic checks: format, lint, type-check
- Comparing versions correctly
- Versions are not text
- Start small
A standard layout
In a well-run kitchen, any cook can find the knives on their first day. Most modern Python projects share a layout for the same reason:
shop-report/
├── pyproject.toml the project's settings: name, version, dependencies, tool config
├── uv.lock exact versions of every dependency (generated, committed)
├── README.md what it is, how to install, run and test it
├── .gitignore files git should never track (.venv, .env, caches)
├── src/
│ └── shop_report/ the package itself
│ ├── __init__.py
│ ├── cli.py the command-line interface (the shell)
│ └── pricing.py the business rules (the core)
├── tests/
│ └── test_pricing.py
└── .github/
└── workflows/
└── ci.yml the CI pipeline
The src/ folder keeps the package separate from everything else, so tests run against the installed package rather than files that happen to sit nearby. Tests live beside the code but not inside it. And the README gets anyone from "cloned it" to "ran the tests" in a few commands.
pyproject.toml
pyproject.toml is the one settings file for a Python project, like the label on a package listing what's inside. It's written in TOML, a simple format of key = value lines grouped under [sections]. Python can read it:
import tomllib text = """ [project] name = "shop-report" version = "1.4.2" requires-python = ">=3.12" dependencies = ["requests>=2.32,<3", "rich~=13.7"] [project.scripts] shop-report = "shop_report.cli:main" [tool.ruff] line-length = 100 """ config = tomllib.loads(text) print(config["project"]["name"], config["project"]["version"]) for dep in config["project"]["dependencies"]: print("needs:", dep) print("command:", config["project"]["scripts"])
[project.scripts] makes a terminal command: after installing, typing shop-report runs main() in shop_report/cli.py. Tools keep their settings under [tool.<name>].
Versions and version ranges
Most packages use semantic versioning: MAJOR.MINOR.PATCH, such as 2.31.4. Think of changes to a house:
- PATCH goes up for bug fixes that change nothing else, like fixing a dripping tap.
- MINOR goes up for new features that don't break existing code, like adding a room.
- MAJOR goes up for breaking changes, like moving the front door. Upgrading may need code changes.
Dependencies say which versions they accept with a specifier:
| specifier | means |
|---|---|
==2.31.4 | exactly that version |
>=2.32 | that version or newer |
>=2.32,<3 | at least 2.32, but not version 3 (commas mean "and") |
~=1.4 | "compatible release": at least 1.4, below 2.0 |
~=1.4.2 | at least 1.4.2, below 1.5.0 |
Ranges let your project pick up bug fixes. But they mean two installs on different days can get different versions, and "it works on my machine" follows.
A recipe might say "flour". A lockfile names the exact brand and bag. It records the exact version of every package, including the packages your packages need. Commit it, and everyone (including CI and production) installs the same thing. uv is a fast modern tool that manages environments, pyproject.toml and uv.lock together:
uv init shop-report # create a project uv add requests # add a dependency (updates pyproject.toml and uv.lock) uv run pytest # run a command inside the project's environment uv sync # install exactly what the lockfile says
In plain words:
pyproject.tomlsays what you'll accept, and the lockfile records what you actually got.
Show the answer
It contains breaking changes, so upgrading may need code changes
A MAJOR bump signals breaking changes. Read the changelog before upgrading.
Automatic checks: format, lint, type-check
Before an editor sees a document, it's been through a spell-checker. Three tools catch problems before a human reviewer has to:
- A formatter rewrites code into one consistent style.
ruff format(or Black) ends all arguments about spaces and line breaks. - A linter flags likely bugs and bad patterns: unused imports, undefined names, a bare
except:, a mutable default argument.ruff checkruns hundreds of rules in a blink. - A type checker (mypy, pyright) checks the type hints.
ruff format . # fix formatting ruff check . --fix # find (and fix what it safely can) lint problems mypy src # check types pytest # run the tests
A pre-commit hook runs some of these automatically before every commit, so mistakes never reach the repository.
Continuous integration
Continuous integration (CI) means a server runs your checks on every push and every pull request. If anything fails, the change is marked red and can't be merged. Here's a typical GitHub Actions workflow, a YAML file in .github/workflows/:
name: CI
on: [push, pull_request] # when to run
jobs:
test:
runs-on: ubuntu-latest # a fresh virtual machine each time
strategy:
matrix:
python-version: ["3.12", "3.13"] # run the job once per version
steps:
- uses: actions/checkout@v4 # get the code
- uses: astral-sh/setup-uv@v6 # install uv
- run: uv sync --python ${{ matrix.python-version }}
- run: uv run ruff check .
- run: uv run mypy src
- run: uv run pytest
The fresh machine matters. A recipe tested in an empty kitchen proves it lists everything you need. A clean machine proves the project works from a clean checkout, with only what the lockfile and README say. With branch protection, the repository refuses to merge a pull request until these checks pass.
Continuous delivery (CD) is the next step: every change that passes CI is released automatically, or ready to release with one click. Phase 11 covers deployment.
CI turns "it works on my machine" into "it works".
Show the answer
So every machine, including CI and production, installs exactly the same versions
Ranges in pyproject.toml say what's allowed; the lockfile says what's actually used, so installs are reproducible.
Comparing versions correctly
Versions look like decimals, but they aren't. Compare them as tuples of numbers.
versions = ["1.10.0", "1.9.3", "1.2.0", "2.0.0"] print(sorted(versions)) # as text: wrong! as_tuples = lambda v: tuple(int(p) for p in v.split(".")) print(sorted(versions, key=as_tuples)) # as numbers: right print(as_tuples("1.10.0") > as_tuples("1.9.3"))
As text, "1.10.0" sorts before "1.9.3", because the character "1" comes before "9". Split into numbers, (1, 10, 0) is bigger than (1, 9, 3), as it should be. Tuples compare item by item, left to right, which matches how versions work.
print("3.10" > "3.9", (3, 10) > (3, 9))
Show the answer
False True
As text, "3.1" is less than "3.9" at the third character, so the first test gives False. As tuples of numbers, 10 beats 9, so the second gives True.
Versions are not text
Here's a bug that's bitten many teams.
print("1.10.0" > "1.9.0") print((1, 10, 0) > (1, 9, 0))
Compared as text, 1.10.0 comes before 1.9.0, since the character 1 sorts before 9. It's the same reason file names sort as "file10" before "file2". Compare them as numbers, part by part. Use a proper library for this in real code, such as packaging.version.
Why lockfiles matter
pyproject.toml says what you need in general: "requests, version 2 or later". A lockfile records exactly what got installed, down to every package's package. Commit it.
Without it, a new teammate installs on Tuesday and gets a newer version of some library than you have. Their code fails, yours doesn't, and you both lose an afternoon to "works on my machine". With a lockfile, everyone gets the same thing, CI included. You upgrade on purpose, when you're ready to test it.
CI isn't only for big teams
If you work alone, CI is still the cheapest safety net you can have. It runs your tests on every push on a clean machine, so it catches the files you forgot to commit and the setup steps you only ever did by hand. It takes one small file to set up, and it works without being asked.
Keep secrets out of both pyproject.toml and the workflow file, since both are committed and anyone who can read the code can read them. GitHub Actions has a separate secrets store. Use that, and refer to it by name.
Start small
You don't buy a full workshop to hang one shelf. A new project needs surprisingly little: a README, pyproject.toml, a src folder, a tests folder and one CI file. You can add the rest when a real need shows up, rather than copying a huge template you don't understand.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What goes in pyproject.toml?