Modules, packages, and pyproject.toml
The import system, the difference between modules and packages, __init__.py and __main__, and pyproject.toml as the single home for dependencies, tool settings, and publishing.
This is the last chapter of Part 1. For six chapters we wrote code in one file. This chapter is about how to split it across multiple files and wire it into a project — the import system, modules and packages, and the pyproject.toml that defines all of it.
The pyproject.toml you create here keeps growing through the rest of the book. Chapter 30 Type-checker setup and CI integration grows the [tool.ruff] / [tool.pyright] sections into proper configuration, and Chapter 32 Publishing a library with uv publishes the first library to PyPI through the [build-system] / [project.scripts] sections. This chapter lays the foundation for all of that.
Module — one file is a module #
In Python, a single .py file is a module. Other places use import to bring it in.
def add(a: int, b: int) -> int:
return a + b
def multiply(a: int, b: int) -> int:
return a * b
PI = 3.141592import math_utils
print(math_utils.add(2, 3)) # 5
print(math_utils.PI) # 3.141592Four forms of import #
# 1. the whole module
import math_utils
math_utils.add(1, 2)
# 2. alias
import math_utils as mu
mu.add(1, 2)
# 3. pull specific names
from math_utils import add, PI
add(1, 2)
# 4. alias + pull
from math_utils import add as plus
plus(1, 2)from m import * is rarely used
#
from math_utils import *Name conflicts, untraceable origins, confusion for static analyzers. Rarely used in new code. The exception is a quick trial in an interactive shell.
Package — a directory is a package #
To group multiple modules, make a directory.
my_app/
├── __init__.py
├── auth.py
├── db.py
└── handlers/
├── __init__.py
├── user.py
└── post.pyIn this structure:
from my_app import auth
from my_app.db import connect
from my_app.handlers.user import create_user__init__.py — two roles
#
__init__.py does two things.
- Marks this directory as a package (historical role — since Python 3.3, it can be omitted and treated as a namespace package)
- Code that runs when the package is imported — organizes the public API or initializes
"""my_app — example application."""
from my_app.auth import login, logout
from my_app.db import connect
__all__ = ["login", "logout", "connect"]
__version__ = "0.1.0"This lets the caller write:
from my_app import login, connectThe internal structure (auth module, db module) is not exposed. The pattern here is to define the public API in one place.
__all__ is a whitelist of names to pull on from my_app import *, but in practice it’s used more often as documentation that declares the public API.
namespace package — when __init__.py is missing
#
Since 3.3+, a directory without __init__.py can also be a package (implicit namespace package). Downsides:
- Nowhere to put initialization code
- Some tools may not recognize it (older versions)
When creating a new package, including __init__.py is safer. It can be empty (no need for even a pass — just an empty file).
Absolute vs. relative imports #
Two ways to import another module within the same package.
# absolute import
from my_app.db import connect
from my_app.auth import current_user
# relative import
from ..db import connect
from ..auth import current_user.. means “one level up”, . means “same directory”.
Both work, but absolute imports are recommended (PEP 8). The source is obvious at a glance, and there’s less chance of breakage when moving files. Relative imports are reasonable only for short distances inside a package.
__main__ — running the module directly
#
A place for code that runs only when executed as a script.
def main():
print("hello!")
if __name__ == "__main__":
main()This file can be used in two ways.
$ uv run cli.py
hello! ← __name__ is '__main__'import cli
cli.main() # calling explicitly works
# main() is not invoked at import timeWithout if __name__ == "__main__":, main runs when imported, causing side effects. Always having this guard is best practice.
Running a package — __main__.py
#
To make the package itself executable:
my_cli/
├── __init__.py
├── __main__.py ← main entry point here
└── core.pyfrom my_cli.core import run
if __name__ == "__main__":
run()Then:
$ uv run python -m my_cliThis pattern is often used when building CLI tools. Chapter 33 Building a CLI tool (Typer) covers it in earnest.
Standard library — the same import system #
The standard library imports the same way. Use it directly with no install.
import os # filesystem
import sys # interpreter, argv
import json # JSON
import re # regex
import datetime # date/time
import pathlib # path objects
from collections import Counter, defaultdict
from itertools import chain, groupby
from functools import lru_cache, partialFor the standard library, the official docs are the fastest reference.
pyproject.toml — the single definition of the project
#
The file created by uv init in Chapter 1. This chapter looks at it in earnest.
[project]
name = "my-app"
version = "0.1.0"
description = "example application"
readme = "README.md"
requires-python = ">=3.14"
authors = [
{ name = "Curtis", email = "you@example.com" },
]
license = { text = "MIT" }
dependencies = [
"httpx>=0.28",
"pydantic>=2.10",
]
[project.optional-dependencies]
docs = ["mkdocs>=1.6"]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.7",
"pyright>=1.1",
]
[project.scripts]
my-app = "my_app.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"The key sections, one by one.
[project] — PEP 621 metadata
#
Name, version, description, required Python version, authors, license, and runtime dependencies.
[project.optional-dependencies] — optional dependencies
#
A bundle that users can opt into via pip install 'my-app[docs]'. Often used when publishing a library.
[dependency-groups] — for development/testing
#
A section standardized by PEP 735. A group for tools needed during development but not for distribution. uv adds them automatically via uv add --dev.
[project.scripts] — CLI entry points
#
When this section exists, console commands are created automatically on package install.
[project.scripts]
my-app = "my_app.cli:main"After install:
$ my-app
# the main function in the my_app.cli module runsIf you’re building a CLI tool, this section is the key. Chapter 33 Building a CLI tool (Typer) wires up the actual CLI’s entry point through this section.
[build-system] — the build backend
#
Needed if you’re publishing to PyPI. There are several options like hatchling, setuptools, pdm-backend. For a new project, hatchling is light and reasonable.
Chapter 32 Publishing a library with uv fills out this section properly and follows the PyPI release process.
Tool settings live in pyproject.toml too
#
Linter / formatter / type checker settings also go in the same file.
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B"]
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.14"
[tool.pytest.ini_options]
testpaths = ["tests"]Scattered files like setup.cfg, .flake8, mypy.ini, and pytest.ini merge into one. This chapter only shows a preview; the proper settings are covered in Chapter 30 Type-checker setup and CI integration.
Daily uv commands — once more #
Adding to what was seen in Chapter 1, the flow you’ll use often:
# new project
uv init my-app --python 3.14
# dependencies
uv add httpx pydantic
uv add --dev pytest ruff pyright
uv remove old-package
# sync (on another machine / on a fresh checkout)
uv sync
# run
uv run python main.py
uv run pytest
uv run ruff check
uv run pyright
# transient run (a tool not in project dependencies)
uvx ruff check . # one-shot run, environment untouched
# upgrade Python itself
uv python install 3.14uvx is a command that runs once in an isolated environment. Same role as pipx — safe because it doesn’t install a tool globally.
Wrapping up Part 1 — the tools we’ve gathered #
Through seven chapters, the basic infrastructure of modern Python is in place. You can read and write at least this kind of code now.
from collections.abc import Callable
type UserId = int
def find_users(
ids: list[UserId],
*,
transform: Callable[[UserId], str] = str,
skip_invalid: bool = True,
) -> dict[UserId, str]:
"""Build a transformed user map from an ID list."""
result: dict[UserId, str] = {}
for uid in ids:
try:
result[uid] = transform(uid)
except ValueError:
if not skip_invalid:
raise
return resultType hints, | union, built-in generics, keyword-only, Callable, exception handling, docstring — all tools covered in Part 1.
Exercises #
- Create a new project
mathkitand hand-build the package structuremathkit/{__init__.py, basic.py, advanced.py, __main__.py}. Putadd,subtractinbasic.pyandfactorialinadvanced.py, and expose the public API via__init__.py. Confirm thatuv run python -m mathkitruns as a package. - In the same project’s
pyproject.toml, addmathkit = "mathkit.__main__:main"under[project.scripts]. Install withuv pip install -e .oruv tool install --from . mathkitand confirm that themathkitcommand works from the shell. - In the same project, add
pytest,ruff, andpyrightto the dev group in[dependency-groups]. Write a simple test intests/test_basic.pythat assertsadd(2, 3) == 5, and confirm thatuv run pytestpasses.
In one line: file = module, directory = package. Prefer absolute imports. Always include the
if __name__ == "__main__":guard; for package-level execution, use__main__.py. A singlepyproject.tomlcollects metadata / dependencies / scripts / tool settings. The daily uv loop isinit / add / sync / run / uvx.
Next chapter #
Part 1 ends and Part 2 Structuring code begins. The first chapter is Chapter 8 dataclass and __slots__ — a tool for making data classes short and safe. The “fixed-shape object” problem gets verbose with only the functions / collections from Part 1, and dataclass gives us a better structure.