pyproject.toml, lockfiles en de src/-layout — moderne projectstructuur
Van setup.py naar pyproject.toml, reproduceerbare builds met uv.lock, __init__.py en de src/-layout — en een platte scriptmap omzetten naar een echt package met een commando voor de commandoregel.
Hoe wil je leren?
Het probleem dat we oplossen
De ticket-triage-code begon als een paar scripts in één map: clean.py, score.py, run_triage.py. De auteur staat in de map, draait python run_triage.py, en alles werkt. Maar daarna, in drie maanden tijd:
- Een collega zette een rapportscript in een submap
scripts/, en dat faalde metModuleNotFoundError: No module named 'score'— terwijlscore.pygewoon in de map ernaast staat. - Iemand maakte in de map een bestand
secrets.pyaan om een API-sleutel in op te slaan, en ineens “brak” de ingebouwdesecrets-module van Python in een ander script. - Een nieuwe collega kwam erbij; de oude
setup.pyen derequirements.txtnoemden de packages op twee verschillende manieren, en niemand wist welke juist was.
Er zit geen bug in de code; de bug zit in de structuur. In dit hoofdstuk maken we van deze map een echt Python-package — met een pyproject.toml, een lockfile en een src/-layout — zoals bijna elke moderne Python-repository bij een bedrijf eruitziet.
Aan het einde van dit hoofdstuk kun je
- Uitleggen waarom Python is overgestapt van
setup.pynaarpyproject.toml, en de belangrijkste secties van eenpyproject.tomllezen - Uitleggen wat
uv.lockvastlegt, enuv lock,uv syncenuv sync --lockedop de juiste plek gebruiken - Een project met
src/-layout aanmaken metuv init --packageen--lib, en uitleggen wat__init__.pydoet - De twee importvallen van een platte map herkennen, en laten zien waarom de
src/-layout ze vroeg vangt - Een platte scriptmap omzetten naar een package met een entry point voor de commandoregel
Wat je eerst nodig hebt: Git, branches en Pull Requests, plus uv init, uv add en uv run uit het hoofdstuk daarvoor.
Van setup.py naar pyproject.toml
In oudere Python-projecten zie je vaak een bestand zoals dit:
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"]},
)En ernaast een requirements.txt, vaak met een andere lijst. Het kernprobleem van setup.py is dat het een programma is: zelfs om de naam of de dependencies van het project te weten, moet je het uitvoeren. En omdat het gewoon Python is, kan iedereen er van alles in zetten — bestanden lezen, voorwaarden, zelfs netwerkaanroepen. Tools konden er dus nooit met zekerheid iets over zeggen.
pyproject.toml bevat dezelfde informatie, maar als een declaratie — een gewoon TOML-bestand dat wordt gelezen, nooit uitgevoerd. Het is nu de Python-standaard, en uv, editors, testtools en linters lezen het allemaal. Dit is wat uv init --package ticket-triage aanmaakt:
[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 haalt de naam en het e-mailadres van de auteur uit je Git-instellingen.) Drie secties:
[project]— de identiteit en de dependencies van het project.uv addbeheert de lijstdependenciesvoor je.[project.scripts]— commando's voor de commandoregel.ticket-triage = "ticket_triage:main"betekent: maak een commandoticket-triagedat de functiemainaanroept in het packageticket_triage.[build-system]— het hulpmiddel dat het project omzet in een installeerbaar package (de “build backend”).uvgebruikt zijn eigenuv_build; in andere projecten zie je misschienhatchlingofsetuptools— met dezelfde taak.
Let op: het project heet ticket-triage (met een koppelteken), maar het Python-package heet ticket_triage (met een underscore). Koppeltekens zijn niet toegestaan in Python-namen — import ticket-triage zou een aftrekking zijn — dus uv zet het om voor je.
Lockfiles: van “het werkte” naar “het werkt altijd op dezelfde manier”
In pyproject.toml schrijf je dependencies als bereiken: "httpx>=0.28.1" betekent 0.28.1 of een latere versie. Dat is bewust — je zegt wat werkt. Maar als je vandaag installeert en over drie maanden opnieuw, kun je twee verschillende versies krijgen, laat staan de dependencies daaronder.
uv.lock dicht dat gat. Het legt de exacte versie en de bestandshashes vast van elk package — ook van de packages die dieper liggen. Met dit bestand in de repository installeren elke machine, de CI en de productieserver precies hetzelfde. Dat is een “reproduceerbare build”.
Vier commando's, vier taken:
uv lock— leestpyproject.tomlen maakt of werkt de lockfile bij. Installeert niets.uv sync— zet.venvprecies gelijk aan de lockfile; werkt de lockfile eerst bij als dat nodig is.uv sync --locked— hetzelfde, maar als de lockfile verouderd is, stopt het in plaats van hem bij te werken.uv lock --check— verandert niets, en meldt alleen of de lockfile overeenkomt metpyproject.toml.
uv add en uv remove werken beide bestanden tegelijk bij, dus bij normaal werk draai je deze niet apart. Het misgaat zodra iemand pyproject.toml met de hand bewerkt. Stel dat iemand dependencies = [] verandert in dependencies = ["httpx>=0.28"] en geen uv lock draait:
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`.Daarom draait CI altijd uv sync --locked: er kan daar geen ongecontroleerde versie stilletjes doorheen glippen.
Gewoonuv syncgeeft hier geen foutmelding. Zonder--lockedin dezelfde situatie berekentuvde versies stilletjes opnieuw, herschrijftuv.lockzelf en gaat door. Op je eigen machine is dat handig; in CI betekent het dat er iets anders wordt getest dan wat in de repository staat.
Packages, __init__.py en de src/-layout
Een module is één .py-bestand. Een package is een map met modules erin, plus een __init__.py. __init__.py doet twee dingen: het markeert de map als importeerbaar package, en het is het bestand dat als eerste draait wanneer je import ticket_triage schrijft — dus alles wat je als “voordeur” van het package wilt tonen, kan daar staan.
Drie vormen van uv init:
uv init— een scriptproject:main.pyenpyproject.toml, zonder build-system. Voor taken van één of twee bestanden en experimenten.uv init --package— een applicatiepackage:src/-layout, een build-system en een commando in[project.scripts].uv init --lib— een bibliotheek die anderen gaan importeren:src/-layout en een build-system, maar geen commando, plus een leeg bestandpy.typeddat type checkers vertelt dat het package typeinformatie meelevert.
De structuur die uv init --package ticket-triage oplevert:
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyHet package staat niet in de hoofdmap van het project maar in een map src/. Om te zien waarom die extra laag bestaat, kijken we eerst naar wat er in een platte map misgaat.
Twee valkuilen in een platte map
Valkuil één: waar Python zoekt. Wanneer je een script draait, zoekt Python imports eerst in de map waarin het script staat — niet waar jij in de terminal staat. In de platte map werkt from score import priority alleen omdat score.py en het script in dezelfde map staan. Dit is het rapportscript van de collega, scripts/report.py:
from score import priority
print(priority("Database breach reported"))Je staat in de map triage-scripts en draait 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'(Op jouw machine toont de traceback het volledige pad; hier is het ingekort.) Python zocht in scripts/, en daar staat geen score.py. Of een import werkt, hangt af van waar het bestand toevallig staat — weinig regels zijn voor een project zo broos.
Valkuil twee: naamconflicten. Door dezelfde regel worden bestanden in de map van het script vóór Pythons eigen modules doorzocht. Iemand maakt een secrets.py aan in de map om een sleutel op te slaan, en het new_id.py ernaast wil de standaardbibliotheek secrets gebruiken:
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 gaf geen fout — het vond het secrets.py van het project en importeerde dat. De fout komt later, wanneer de code iets vraagt wat dat bestand niet heeft. In AI-projecten gebeurt dit vaak met experimentele bestanden met namen als openai.py, anthropic.py of httpx.py, die precies de bibliotheek verbergen die je probeert te gebruiken.
Hoe de src/-layout deze fouten vroeg vangt
Er veranderen twee dingen in een src/-layout.
Ten eerste staat alles onder één unieke naam. De modules heten niet meer clean, score, secrets — ze heten ticket_triage.clean, ticket_triage.score. Geen enkel bestand in het project kan de standaardbibliotheek of een ander package verbergen, en de imports zelf laten zien welke code van jou is.
Ten tweede kan het package niet worden geïmporteerd zonder dat het geïnstalleerd is. Dit probeer je vanuit de hoofdmap van het project met gewoon python:
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'De fout is juist de bedoeling. Als het package in de hoofdmap stond, zou dit commando werken — Python zou de map direct vinden — en je zou nooit merken of de configuratie in pyproject.toml klopt. In src/ is er maar één manier om het package te bereiken: het installeren. En uv run doet dat elke keer — het installeert het project in .venv als “editable”, wat betekent dat het geïnstalleerde package terugwijst naar je src/-map, zodat je na elke codewijziging niet opnieuw hoeft te installeren:
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highElke fout in de packaging komt dus al op je eigen machine naar boven, bij de eerste uv run — niet pas op de dag van de uitrol. Als de packagemap bijvoorbeeld een verkeerde naam heeft of __init__.py ontbreekt:
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(Het pad is ingekort.) De build-backend leidt de packagenaam af van de projectnaam — ticket-triage wordt src/ticket_triage/ — en stopt als er daar niets staat. Wil je bewust een andere naam, geef die dan op met module-name onder [tool.uv.build-backend] in pyproject.toml.
Een compleet voorbeeld
Laten we nu de platte map van het begin van het hoofdstuk omzetten. Na uv init --package ticket-triage --python 3.12 in een buurmap, en nadat we de oude bestanden in het package hebben verplaatst, ziet de structuur er zo uit:
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyEén wijziging in pyproject.toml — een kortere commandonaam, die wijst naar de nieuwe module cli:
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — het oude clean.py, nu met type hints en een 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 — de enige echte wijziging is de importregel: from clean import ... wordt nu 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 — de voordeur van het package:
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — het script dat eerder brak, nu met de import via de packagenaam:
from ticket_triage import priority
print(priority("Database breach reported"))En src/ticket_triage/cli.py — het entry point voor de commandoregel dat het oude run_triage.py vervangt:
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()Draai dit vanuit de hoofdmap van het project:
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 2msDe allereerste uv run triage print een paar regels extra — het aanmaken van .venv, daarna Building ticket-triage en Built ticket-triage — omdat uv het project voor de eerste keer installeert; daarna verschijnen die niet meer. De laatste regel komt van uv lock --check, en de exitcode daarvan is 0: de lockfile klopt met pyproject.toml.
De keuzes in deze omzetting:
Absolute imports (from ticket_triage.clean import ...). Relatieve imports werken ook binnen een package — from .clean import clean_text — maar absolute imports lezen hetzelfde vanuit elk bestand en zijn makkelijker te zoeken. Wat je ook kiest, houd per project één stijl aan.
Alleen priority in __init__.py. Code van buitenaf (scripts/report.py, toekomstige tests, andere services) kent alleen from ticket_triage import priority. Als de verdeling tussen score.py en clean.py later verandert, breekt die externe code niet. __all__ schrijft die publieke lijst expliciet op.
Een apart cli.py. Wanneer logica (score.py) en invoer/uitvoer (cli.py) gescheiden zijn, kan priority vanuit overal worden aangeroepen — een test, een API — zonder sys.argv of print. Als sys.argv[1:] leeg is, draaien de demotickets, zodat het programma ook zonder argumenten iets laat zien.
f"{priority(ticket):<6}". :<6 lijnt de tekst links uit op zes tekens, zodat de tickets na zowel high als normal in dezelfde kolom beginnen.
Eén beperking moet je kennen: clean_text splitst woorden alleen op witruimte, dus in "The site is down." is het woord down. — inclusief de punt — en komt het niet overeen met URGENT_WORDS. Betere manieren om woorden uit tekst te halen komen later in de cursus; dit hoofdstuk gaat over de structuur, en omdat de structuur klopt, gebeurt die verbetering in clean.py en nergens anders.
Wanneer het breekt
ModuleNotFoundError: No module named 'clean' De bestanden zijn naar het package verplaatst, maar een bestand heeft nog de oude import from clean import clean_text. Er is binnen een package geen module op het hoogste niveau die clean heet; schrijf from ticket_triage.clean import clean_text. Zoek na het omzetten het hele project door op from clean en from score.
ModuleNotFoundError: No module named 'ticket_triage' Het is gestart met gewoon python in plaats van uv run, dus de omgeving waarin het package geïnstalleerd is, is niet gebruikt. Draai het met uv run .... Dit is de src/-layout die doet wat hij moet doen, geen bug.
Expected a Python module at: src\ticket_triage\__init__.py Het package staat niet in de map die de build-backend verwacht op basis van de projectnaam — de map heeft een verkeerde naam (src/triage/, src/ticket-triage/), of __init__.py ontbreekt. Verbeter de mapnaam, voeg __init__.py toe, of geef de echte naam op met module-name.
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml is met de hand bewerkt en uv lock is niet gedraaid, of de lockfile is niet gecommit. Draai uv lock en commit beide bestanden samen. Wijzig de volgende keer dependencies met uv add en uv remove — die houden beide bestanden synchroon.
AttributeError: module 'secrets' has no attribute 'token_hex' Het project bevat een bestand dat de naam van een module uit de standaardbibliotheek heeft (secrets.py, random.py, logging.py) en daardoor de echte module verbergt. Hernoem het bestand — en bewaar geheimen in .env, niet in een .py-bestand.
error: Failed to spawn: triag gevolgd door Caused by: program not found De naam die je aan uv run geeft, wijkt af van die in [project.scripts] — hier een typfout. Controleer de naam in pyproject.toml; na het hernoemen installeert uv run het project vanzelf opnieuw en maakt het nieuwe commando aan.
Stap 4 van 6 — Voorspellen
Test je begrip
De clean_text uit het hoofdstuk wordt uitgevoerd op een rommelig ticket. Wat wordt geprint?
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'`
Na de overstap van de platte map naar een src/-layout geeft uv run triage ModuleNotFoundError: No module named 'clean'. clean.py staat er vlak naast, op src/ticket_triage/clean.py. Wat is de 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` moet terug naar de hoofdmap van het project, want daar zoekt Python
- BDe import is nog in de stijl van de platte map; binnen het package moet die `from ticket_triage.clean import clean_text` zijn
- CMet een `__init__.py` in `src/ticket_triage/` kunnen naburige bestanden niet worden geïmporteerd; die moet worden verwijderd
- D`uv run triage` kijkt alleen naar `cli.py`; imports in `score.py` worden nooit uitgevoerd
Iemand heeft in een PR pyproject.toml met de hand bewerkt om een package toe te voegen. De CI-stap uv sync --locked faalt: The lockfile at uv.lock needs to be updated. Wat is de juiste oplossing?
- AHaal `--locked` weg uit de CI, zodat de CI de lockfile zelf bijwerkt
- BZet `uv.lock` in `.gitignore`, zodat iedereen zijn eigen lockfile bouwt
- CDraai lokaal `uv lock` (of gebruik vanaf het begin `uv add`) en commit de bijgewerkte `uv.lock` in dezelfde PR
- DVerwijder `uv.lock`, want zonder lockfile kan deze fout niet optreden
Spelen hoort bij Pro
+30 XP wacht op je
Speel dit hoofdstuk met Pro
Lezen is gratis en het hele hoofdstuk is open. De eerste 5 hoofdstukken van elke gratis cursus speel je gratis. Vanaf hoofdstuk 6 horen vragen beantwoorden, XP verdienen en je reeks laten groeien bij Pro.
Ontgrendel spelen met ProJouw beurt
Bouw zelf de platte map van het begin van het hoofdstuk en zet die om. Maak eerst drie bestanden in een map triage-scripts/: clean.py (de clean_text hierboven, zonder type hints), score.py (beginnend met from clean import clean_text), en run_triage.py, dat de prioriteit van twee demotickets print. Daarna:
- Maak ernaast een nieuw project aan met
uv init --package ticket-triage --python 3.12, verplaats de bestanden naarsrc/ticket_triage/, en schrijf elke import metticket_triage. - Zet
run_triage.pyom naar een functiemain()incli.py, zoals in dit hoofdstuk (met dezelfde twee demotickets), en voegtriage = "ticket_triage.cli:main"toe aan[project.scripts] - Toon alleen
priorityvanuit__init__.py, en gebruik die inscripts/report.pymetfrom ticket_triage import priority - Draai
uv lock --checkom te bevestigen dat de lockfile klopt, en commitpyproject.toml,uv.lock,src/enscripts/in één commit — zonder.venv
Vanuit de hoofdmap van het project zou uv run triage gevolgd door uv run scripts/report.py precies dit moeten geven:
normal Login page is slow
high Payments DOWN for all users
highBreek het daarna met opzet, twee keer. Zet één keer de import in score.py terug naar from clean import clean_text en kijk welke fout verschijnt, en op welke regel. Een andere keer maak je een secrets.py aan in src/ticket_triage/, voeg je import secrets en print(len(secrets.token_hex(8))) toe aan cli.py, en draai je uv run triage — werkt de valkuil van de platte map hier nog, en waarom niet?
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Speel met Pro