Contents
7 Chapter

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.

math_utils.py
def add(a: int, b: int) -> int:
    return a + b

def multiply(a: int, b: int) -> int:
    return a * b

PI = 3.141592
main.py
import math_utils

print(math_utils.add(2, 3))     # 5
print(math_utils.PI)            # 3.141592

Four forms of import #

import variants
# 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 #

🚫 usually avoided
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.

package structure
my_app/
├── __init__.py
├── auth.py
├── db.py
└── handlers/
    ├── __init__.py
    ├── user.py
    └── post.py

In this structure:

package import
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.

  1. Marks this directory as a package (historical role — since Python 3.3, it can be omitted and treated as a namespace package)
  2. Code that runs when the package is imported — organizes the public API or initializes
my_app/__init__.py
"""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:

shortened import
from my_app import login, connect

The 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.

my_app/handlers/user.py
# 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.

cli.py
def main():
    print("hello!")

if __name__ == "__main__":
    main()

This file can be used in two ways.

run directly
$ uv run cli.py
hello!         ← __name__ is '__main__'
import from elsewhere
import cli
cli.main()    # calling explicitly works
# main() is not invoked at import time

Without 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:

executable package
my_cli/
├── __init__.py
├── __main__.py     ← main entry point here
└── core.py
my_cli/__main__.py
from my_cli.core import run

if __name__ == "__main__":
    run()

Then:

run as a package
$ uv run python -m my_cli

This 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.

frequently-used standard modules
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, partial

For 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.

pyproject.toml
[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 runs

If 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 settings
[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:

daily flow
# 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.14

uvx 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.

one-line summary of what we can do with the tools so far
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 result

Type hints, | union, built-in generics, keyword-only, Callable, exception handling, docstring — all tools covered in Part 1.

Exercises #

  1. Create a new project mathkit and hand-build the package structure mathkit/{__init__.py, basic.py, advanced.py, __main__.py}. Put add, subtract in basic.py and factorial in advanced.py, and expose the public API via __init__.py. Confirm that uv run python -m mathkit runs as a package.
  2. In the same project’s pyproject.toml, add mathkit = "mathkit.__main__:main" under [project.scripts]. Install with uv pip install -e . or uv tool install --from . mathkit and confirm that the mathkit command works from the shell.
  3. In the same project, add pytest, ruff, and pyright to the dev group in [dependency-groups]. Write a simple test in tests/test_basic.py that asserts add(2, 3) == 5, and confirm that uv run pytest passes.

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 single pyproject.toml collects metadata / dependencies / scripts / tool settings. The daily uv loop is init / 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.

X