Packaging and Resources
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 buildThat 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.pyOutput:
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.pyOutput:
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.pyOutput:
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.tomlwith[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, notPath("filename"). - Build with
uv build. Do not hand-rollsetup.pyfor a new project. - The current working directory is not part of your package’s API.
Try this
- In
read_banner.py, adddeskpkg/label.txtwithticket 7and print it the same way as the banner. - Add a
README.mdone-liner to the inline project in Case 1 (the[project] readmefield names it). - In
use_label.py, also printdeskpkg.__name__.