Packaging and Resources

Updated

September 8, 2026

Packaging and Resources

A shippable library is a package with a pyproject.toml, not a pile of scripts. Data files travel with the package and are read through importlib.resources, not open("banner.txt") relative to whoever’s current directory.

Mental model

[project] in pyproject.toml is the public name, version, and description. [build-system] names the backend. hatchling is a boring default; uv build is how this book builds.

A package is a directory with __init__.py (or a namespace you chose on purpose). Import deskpkg, do not hope banner.txt is next to the shell.

importlib.resources.files("deskpkg") is a traversable of files inside that package, whether it lives as a directory or inside a wheel. read_text still takes encoding="utf-8".

Worked examples

Case 1: The package layout (inline)

Save these three files as a tiny project. You do not have to install it to learn the shape.

pyproject.toml:

[project]
name = "deskpkg"
version = "1.0.0"
description = "Desk labels and a banner."
requires-python = ">=3.14"
readme = "README.md"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

deskpkg/__init__.py:

# deskpkg/__init__.py
__version__ = "1.0.0"


def label(ticket_id: int, table: int) -> str:
    return f"ticket {ticket_id} → table {table}"

deskpkg/banner.txt:

desk is open

Hatchling includes package data next to Python modules by default for this layout. If a backend does not, you declare the files in pyproject.toml instead of hoping.

Build (from that project directory):

uv build

That writes a wheel and an sdist under dist/. Installing is uv pip install dist/deskpkg-1.0.0-py3-none-any.whl (the exact wheel name follows the version).

Case 2: Read a resource without a cwd guess

Save as read_banner.py. This listing creates the package in a temporary directory so it runs as a complete program. Production code imports deskpkg after install and skips the sys.path dance.

# read_banner.py
import importlib.resources
import sys
from pathlib import Path
from tempfile import TemporaryDirectory


def main() -> None:
    with TemporaryDirectory() as raw:
        root = Path(raw)
        pkg = root / "deskpkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text(
            '__version__ = "1.0.0"\n', encoding="utf-8"
        )
        (pkg / "banner.txt").write_text("desk is open\n", encoding="utf-8")
        sys.path.insert(0, str(root))
        import deskpkg

        banner = (
            importlib.resources.files("deskpkg")
            .joinpath("banner.txt")
            .read_text(encoding="utf-8")
        )
        print(deskpkg.__version__)
        print(banner, end="")


if __name__ == "__main__":
    main()

Run:

uv run python read_banner.py

Output:

1.0.0
desk is open

Case 3: Call the public function after import

Save as use_label.py. Same temp package, no banner this time.

# use_label.py
import sys
from pathlib import Path
from tempfile import TemporaryDirectory


def main() -> None:
    with TemporaryDirectory() as raw:
        root = Path(raw)
        pkg = root / "deskpkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text(
            "def label(ticket_id, table):\n"
            '    return f"ticket {ticket_id} → table {table}"\n',
            encoding="utf-8",
        )
        sys.path.insert(0, str(root))
        from deskpkg import label

        print(label(7, 12))


if __name__ == "__main__":
    main()

Run:

uv run python use_label.py

Output:

ticket 7 → table 12

The trap

Opening a data file by name looks fine from the directory where you wrote it.

Save as cwd_banner.py:

# cwd_banner.py
from pathlib import Path


def main() -> None:
    path = Path("banner.txt")
    try:
        print(path.read_text(encoding="utf-8"), end="")
    except FileNotFoundError:
        print("banner.txt is not in the current directory")


if __name__ == "__main__":
    main()

Run:

uv run python cwd_banner.py

Output:

banner.txt is not in the current directory

A user who runs uv run python /path/to/cwd_banner.py from $HOME will hit this every time. importlib.resources is the fix.

The boring rule

  • One pyproject.toml with [project] and a declared [build-system] (hatchling is enough).
  • Put library code in a package directory. Keep __version__ in one place.
  • Read bundled files with importlib.resources, not Path("filename").
  • Build with uv build. Do not hand-roll setup.py for a new project.
  • The current working directory is not part of your package’s API.

Try this

  1. In read_banner.py, add deskpkg/label.txt with ticket 7 and print it the same way as the banner.
  2. Add a README.md one-liner to the inline project in Case 1 (the [project] readme field names it).
  3. In use_label.py, also print deskpkg.__name__.