PYTHON / MODULES AND PACKAGES
Project layout and pyproject.toml
Lay out a Python project with a src directory and write a pyproject.toml whose build-system, project, and tool tables actually describe it.
What you will learn
- Read pyproject.toml with tomllib and inspect its three kinds of tables
- Choose src layout so imports resolve to the installed package, not the repo copy
- Declare name, version, requires-python, dependencies, and console scripts in [project]
- Point the build backend at the right directory so the wheel contains your code
Understanding Project layout and pyproject.toml
A project directory and an importable package are two different things. The importer only cares about directories on sys.path; the build backend only cares about what you tell it to copy into a wheel. pyproject.toml is the file that connects them: it names the distribution, lists its dependencies, and tells pip which backend to run and which directories that backend should package.
The file has three distinct kinds of tables and mixing them up is the usual source of confusion. [build-system] is bootstrap information for pip: requires lists what must be installed in a throwaway environment before your project can be built, and build-backend names the Python object that does the building. [project] is standardised metadata that ends up in the wheel and in importlib.metadata: name, version, requires-python, dependencies, scripts. Anything under [tool.something] is not understood by pip at all; it is a namespaced scratchpad that black, ruff, mypy, pytest, or your build backend read for themselves.
Layout is the other half of the decision. In a flat layout the package directory sits next to pyproject.toml, so the interpreter started in the project root finds it through sys.path[0] whether or not you installed anything. In a src layout the package lives at src/yourpkg, which is not on sys.path, so `import yourpkg` only works after `pip install -e .`. That extra step is the point: your tests then exercise the same files a user would get, and a data file you forgot to include fails at your desk instead of on PyPI.
import tomllib
pyproject = """
[build-system]
requires = ["hatchling>=1.24"]
build-backend = "hatchling.build"
[project]
name = "weathervane"
version = "0.3.1"
requires-python = ">=3.11"
dependencies = ["httpx>=0.27", "click>=8.1"]
[project.scripts]
weathervane = "weathervane.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/weathervane"]
"""
data = tomllib.loads(pyproject)
project = data["project"]
print("distribution:", project["name"], project["version"])
print("backend:", data["build-system"]["build-backend"])
print("needs to build:", data["build-system"]["requires"])
for dep in project["dependencies"]:
print("runtime dep:", dep)
print("console script:", project["scripts"]["weathervane"])
print("wheel packages:", data["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"])pyproject.toml declares who builds the project and what its metadata is, while the directory layout decides what is importable, so both must point at the same package.
Worked examples
Why src layout is not importable by accident
Builds a flat-layout and a src-layout project on disk and shows that only the flat one imports without being installed.
import sys, tempfile, pathlib, importlib
root = pathlib.Path(tempfile.mkdtemp())
(root / "flatproj" / "widget").mkdir(parents=True)
(root / "flatproj" / "widget" / "__init__.py").write_text("ORIGIN = 'flat'\n")
(root / "srcproj" / "src" / "widget").mkdir(parents=True)
(root / "srcproj" / "src" / "widget" / "__init__.py").write_text("ORIGIN = 'src'\n")
for project in ("flatproj", "srcproj"):
sys.path.insert(0, str(root / project))
try:
mod = importlib.import_module("widget")
print(project, "->", mod.ORIGIN)
except ModuleNotFoundError as exc:
print(project, "->", exc)
finally:
sys.path.pop(0)
sys.modules.pop("widget", None)Example explained
Line 1sys.path.insert(0, ...) imitates running Python from the project root, which is what puts the root directory on the import path.
Line 2The flat project resolves because widget/ is a direct child of that directory, so the path finder sees it with no install step.
Line 3The src project fails because src/ is on the path only if a build backend or an editable install puts src/ there.
Line 4sys.modules.pop clears the cached module so the second import is a genuine lookup rather than a cache hit.
Checking that [project] is complete
Parses an incomplete [project] table and reports which metadata a build backend would reject or fill in.
import tomllib
text = """
[project]
name = "widget"
dependencies = ["requests>=2.32"]
[tool.black]
line-length = 88
[tool.pytest.ini_options]
testpaths = ["tests"]
"""
data = tomllib.loads(text)
project = data["project"]
has_version = "version" in project or "version" in project.get("dynamic", [])
print("name:", project["name"])
print("version resolvable:", has_version)
print("requires-python declared:", "requires-python" in project)
print("tool tables pip ignores:", sorted(data["tool"]))Example explained
Line 1name is the only field the metadata standard always demands, so reading it directly is safe here.
Line 2version must be either a literal string or listed in dynamic, which is why the check looks in both places; this table has neither and the build fails.
Line 3Omitting requires-python is legal but means old interpreters will download the wheel and then crash on your syntax.
Line 4Keys under [tool] are passed over by pip entirely, so a typo there produces no error, just a tool that silently uses defaults.
Important notes
tomllib reads TOML but cannot write it; use tomli_w or tomlkit if you need to generate or edit pyproject.toml programmatically, and note tomllib.load requires a file opened in binary mode.
A project with pyproject.toml needs no setup.py at all; if you keep one alongside it, the [project] table wins for any field declared in both, which is a common source of a version that refuses to change.
Common mistakes
Keeping the package next to pyproject.toml and running tests from the root: imports hit the working-copy directory, so a module or data file missing from the wheel passes every test and breaks for the first user who installs it.
Adding a dependency to the [project] dependencies list and expecting it to appear: nothing installs it until you rerun pip install -e ., so the import error looks like a broken package rather than a stale environment.
Writing requires_python or build_backend with underscores, or misspelling a [tool.x] table: TOML parses fine, the key is simply not the one anything reads, so the constraint or setting is silently ignored.
Try it yourself
Change, predict, then run
Parse a pyproject.toml string with tomllib for a distribution named "tempo" whose import package is src/tempo_core, then print the distribution name, the import package directory, and whether the two names differ.
Open the Python workspaceCheck your understanding
Your tests pass locally but users report ModuleNotFoundError for yourpkg.templates after installing your wheel. Which layout choice would have surfaced this before release?
- Moving the package to src/yourpkg so tests import the installed copy rather than the repo directory
- Adding yourpkg.templates to [project] dependencies so pip installs it
- Pinning the build backend to an exact version in [build-system] requires
- Adding requires-python = ">=3.11" so the wheel is only offered to newer interpreters
Show answer
With src layout the package is not on sys.path, so tests only run after an install and exercise exactly the files the wheel contains, exposing the missing subpackage immediately. Listing it in dependencies cannot help: dependencies name other distributions from an index, not directories inside your own project.