pyproject.toml, lockfiles and the src/ layout — modern project structure
From setup.py to pyproject.toml, reproducible builds with uv.lock, __init__.py and the src/ layout — and converting a flat script folder into a real package with a command.
How do you want to learn?
The problem we are solving
The ticket-triage work started as a few scripts in one folder: clean.py, score.py, run_triage.py. The author stands inside the folder, runs python run_triage.py, and everything works. Then, over three months:
- A colleague put a report script in a
scripts/subfolder, and it failed withModuleNotFoundError: No module named 'score'— even thoughscore.pyis right there in the folder next door. - Someone created a file called
secrets.pyin the folder to hold an API key, and suddenly Python's ownsecretsmodule "broke" in another script. - A new person joined; the old
setup.pyand therequirements.txtlisted packages two different ways, and nobody knew which was right.
There is no bug in the code; the bug is in the structure. In this chapter we turn this folder into a real Python package — a pyproject.toml, a lockfile and a src/ layout — which is what nearly every modern Python repository at a company looks like.
By the end of this chapter you can
- Say why Python moved from
setup.pytopyproject.toml, and read the main sections of apyproject.toml - Explain what
uv.lockpins, and useuv lock,uv syncanduv sync --lockedin the right places - Create a
src/-layout project withuv init --packageand--lib, and say what__init__.pydoes - Recognise the two import traps of a flat folder, and show why the
src/layout catches them early - Convert a flat script folder into a package with a command-line entry point
Prerequisites: Git, branches and Pull Requests, plus uv init, uv add and uv run from the chapter before it.
From setup.py to pyproject.toml
In older Python projects you will often see a file like this:
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)And next to it a requirements.txt, often with a different list. The core problem with setup.py is that it is a program: even to learn the project's name or dependencies you have to run it, and since it is Python, anyone can put anything inside — reading files, conditions, even network calls. Tools could never say anything about it with certainty.
pyproject.toml holds the same information as a declaration — a plain TOML file that is read, never run. It is now the Python standard, and uv, editors, test tools and linters all read it. Here is what uv init --package ticket-triage produces:
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv takes the author's name and email from your Git settings.) Three sections:
[project]— the project's identity and dependencies.uv addmanages thedependencieslist for you.[project.scripts]— command-line commands.ticket-triage = "ticket_triage:main"means: create a command calledticket-triagethat calls themainfunction in theticket_triagepackage.[build-system]— the tool that turns the project into an installable package (the "build backend").uvuses its ownuv_build; in other projects you may seehatchlingorsetuptools— same job.
Notice that the project is named ticket-triage (hyphen) but the Python package is ticket_triage (underscore). Hyphens are not allowed in Python names — import ticket-triage would be a subtraction — so uv converts it for you.
Lockfiles: from "it worked" to "it always works the same way"
In pyproject.toml, dependencies are written as ranges: "httpx>=0.28.1" means 0.28.1 or any later version. That is deliberate — you are saying what will work. But installing today and installing in three months can bring two different versions, never mind the dependencies underneath.
uv.lock closes that gap. It records the exact version and the file hashes of every package — including the ones further down. With it in the repository, every machine, CI and the production server install the same thing. That is a "reproducible build".
Four commands, four jobs:
uv lock— readspyproject.tomland creates or updates the lockfile. Installs nothing.uv sync— makes.venvmatch the lockfile exactly; updates the lockfile first if needed.uv sync --locked— the same, but if the lockfile is stale it stops instead of updating it.uv lock --check— changes nothing, only reports whether the lockfile matchespyproject.toml.
uv add and uv remove update both files together, so in normal work you do not call these separately. The trouble starts when someone edits pyproject.toml by hand. Say someone changes dependencies = [] to dependencies = ["httpx>=0.28"] by hand and does not run uv lock:
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.That is why CI always runs uv sync --locked: no unreviewed version can slip in quietly there.
Plainuv syncraises no error here. Run without--lockedin the same situation,uvquietly re-resolves, rewritesuv.lockby itself and carries on. On your own machine that is convenient; in CI it means something other than what is in the repository is being tested.
Packages, __init__.py and the src/ layout
A module is one .py file. A package is a folder of modules with an __init__.py inside. __init__.py does two jobs: it marks the folder as an importable package, and it is the file that runs first when you write import ticket_triage — so whatever you want to present as the package's "front door" can live there.
Three forms of uv init:
uv init— a script project:main.pyandpyproject.toml, no build system. For one- or two-file jobs and experiments.uv init --package— an application package:src/layout, a build system and a command in[project.scripts].uv init --lib— a library that others will import:src/layout and a build system, no command, plus an emptypy.typedfile that tells type checkers the package ships type information.
The structure from uv init --package ticket-triage:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyThe package does not sit at the project root but inside a src/ folder. To see why that one extra level exists, first look at what goes wrong in a flat folder.
Two traps in a flat folder
Trap one: where Python looks. When you run a script, Python looks for imports first in the folder the script is in — not where you are standing in the terminal. In the flat folder, from score import priority works only because score.py and the script share a folder. The colleague's scripts/report.py:
from score import priority
print(priority("Database breach reported"))Standing inside the triage-scripts folder and running python scripts/report.py:
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(On your machine the traceback shows the full path; it is shortened here.) Python looked in scripts/, and there is no score.py there. Whether an import works depends on where the file happens to be — few rules are more fragile for a project.
Trap two: name collisions. Because of the same rule, files in the script's folder are searched before Python's own modules. Someone creates secrets.py in the folder to hold a key, and the new_id.py next to it wants the standard library's secrets:
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets raised no error — it found the project's secrets.py and imported that. The error comes later, when the code asks for something that file does not have. In AI projects this happens often with experimental files named openai.py, anthropic.py or httpx.py, which hide the very library you are trying to use.
How the src/ layout catches these mistakes early
Two things change in a src/ layout.
First, everything lives under one unique name. The modules are no longer clean, score, secrets — they are ticket_triage.clean, ticket_triage.score. No file in the project can hide the standard library or another package, and the imports themselves show which code is yours.
Second, the package cannot be imported without being installed. Trying from the project root with plain python:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'The failure is the feature. If the package sat at the project root, this command would work — Python would find the folder directly — and you would never learn whether the configuration in pyproject.toml is right. Inside src/, there is only one way to reach the package: installing it. And uv run does that every time — it installs the project into .venv as "editable", meaning the installed package points back at your src/ folder, so you do not reinstall after each code change:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highSo any packaging mistake shows up on your own machine, on the first uv run — not on deployment day. For example, if the package folder is misnamed or __init__.py is missing:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(The path is shortened.) The build backend derives the package name from the project name — ticket-triage to src/ticket_triage/ — and stops if it finds nothing there. If you want a different name on purpose, say so with module-name under [tool.uv.build-backend] in pyproject.toml.
A complete example
Now let us convert the flat folder from the start of the chapter. After running uv init --package ticket-triage --python 3.12 in a neighbouring folder and moving the old files inside the package, the structure ends up like this:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyOne change in pyproject.toml — a shorter command name, pointing at the new cli module:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — the old clean.py, now with type hints and a docstring:
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — the only real change is the import line: from clean import ... is now from ticket_triage.clean import ...:
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — the package's front door:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — the script that broke before, now importing through the package name:
from ticket_triage import priority
print(priority("Database breach reported"))And src/ticket_triage/cli.py — the command-line entry point replacing the old run_triage.py:
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()Run from the project root:
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msThe very first uv run triage prints a few lines before this — creating .venv, then Building ticket-triage and Built ticket-triage — because uv is installing the project for the first time; after that they no longer appear. The last line is from uv lock --check, and its exit code is 0: the lockfile matches pyproject.toml.
The decisions in this conversion:
Absolute imports (from ticket_triage.clean import ...). Relative imports also work inside a package — from .clean import clean_text — but absolute imports read the same from any file and are easier to search for. Whichever you choose, keep one style per project.
Only priority in __init__.py. Outside code (scripts/report.py, future tests, other services) knows only from ticket_triage import priority. If the split between score.py and clean.py changes later, outside code does not break. __all__ writes that public list down explicitly.
A separate cli.py. With logic (score.py) and input/output (cli.py) apart, priority can be called from anywhere — a test, an API — without sys.argv or print. When sys.argv[1:] is empty the demo tickets run, so running it with no arguments still shows something.
f"{priority(ticket):<6}". :<6 left-aligns the text in six characters, so the tickets start in the same column after both high and normal.
One limitation is worth knowing: clean_text splits words only on whitespace, so in "The site is down." the word is down. — full stop included — and it does not match URGENT_WORDS. Better ways of extracting words from text are coming a little later in the course; this chapter is about structure, and because the structure is right, that improvement will happen in clean.py and nowhere else.
When it breaks
ModuleNotFoundError: No module named 'clean' The files were moved into the package, but some file still has the old import from clean import clean_text. There is no top-level module called clean inside a package; write from ticket_triage.clean import clean_text. After converting, search the whole project for from clean and from score.
ModuleNotFoundError: No module named 'ticket_triage' It was run with plain python instead of uv run, so the environment with the package installed was not used. Run it with uv run .... This is the src/ layout behaving as intended, not a bug.
Expected a Python module at: src\ticket_triage\__init__.py The package is not in the folder the build backend expects from the project name — the folder is misnamed (src/triage/, src/ticket-triage/), or __init__.py is missing. Fix the folder name, add __init__.py, or state the real name with module-name.
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml was edited by hand and uv lock was not run, or the lockfile was not committed. Run uv lock and commit both files together. Next time, change dependencies with uv add and uv remove — they keep both files in step.
AttributeError: module 'secrets' has no attribute 'token_hex' The project contains a file named after a standard-library module (secrets.py, random.py, logging.py) that hides the real one. Rename the file — and keep secrets in .env, not in a .py file.
error: Failed to spawn: triag followed by Caused by: program not found The name given to uv run differs from the one in [project.scripts] — here, a typo. Check the name in pyproject.toml; after renaming, uv run reinstalls the project by itself and creates the new command.
Step 4 of 6 — Predict
Check your understanding
The chapter's clean_text is run on a messy ticket. What prints?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
After moving from the flat folder to a src/ layout, uv run triage gives ModuleNotFoundError: No module named 'clean'. clean.py is right next to it, at src/ticket_triage/clean.py. What is the bug?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` must move back to the project root, because that is where Python looks
- BThe import is still flat-folder style; inside the package it must be `from ticket_triage.clean import clean_text`
- CWith an `__init__.py` in `src/ticket_triage/`, neighbouring files cannot be imported; it must be deleted
- D`uv run triage` only looks at `cli.py`; imports in `score.py` never run
In a PR, someone edited pyproject.toml by hand to add a package. CI's uv sync --locked fails: The lockfile at uv.lock needs to be updated. What is the right fix?
- ARemove `--locked` from CI so that CI updates the lockfile itself
- BAdd `uv.lock` to `.gitignore` so everyone builds their own lockfile
- CRun `uv lock` locally (or use `uv add` in the first place) and commit the updated `uv.lock` in the same PR
- DDelete `uv.lock`, since without a lockfile this error cannot appear
Play is part of Pro
+30 XP waiting
Play this chapter with Pro
Reading is free, and the whole chapter is open. The first 5 chapters of every free course are free to play. From chapter 6, answering the questions, earning XP and growing your streak is part of Pro.
Unlock Play with ProYour turn
Build the flat folder from the start of the chapter yourself and convert it. First put three files in a triage-scripts/ folder: clean.py (the clean_text above, without type hints), score.py (starting with from clean import clean_text), and run_triage.py, which prints the priority of two demo tickets. Then:
- Next to it, create a new project with
uv init --package ticket-triage --python 3.12, move the files intosrc/ticket_triage/, and write every import withticket_triage. - Turn
run_triage.pyinto amain()function incli.pyas in this chapter (with the same two demo tickets), and addtriage = "ticket_triage.cli:main"to[project.scripts] - Expose only
priorityfrom__init__.py, and use it fromscripts/report.pywithfrom ticket_triage import priority - Run
uv lock --checkto confirm the lockfile matches, and commitpyproject.toml,uv.lock,src/andscripts/in one commit — without.venv
From the project root, uv run triage followed by uv run scripts/report.py should give exactly this:
normal Login page is slow
high Payments DOWN for all users
highThen break it on purpose, twice. Once, change the import in score.py back to from clean import clean_text and see which error appears, and on which line. Another time, create a secrets.py inside src/ticket_triage/, add import secrets and print(len(secrets.token_hex(8))) to cli.py, and run uv run triage — does the flat-folder trap still work here, and why not?
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Play with Pro