Most CLIs carry files that are not Python: project templates for an init command, a JSON schema for validating config, a default configuration, SQL migrations, shell completion scripts. In the source tree they sit next to the code and Path(__file__).parent / "templates" finds them. After packaging, that approach breaks in several ways — the files were never included in the wheel, or the package runs from a zip file where __file__ points inside an archive, or a frozen binary lays files out differently. importlib.resources is the standard-library API designed for exactly this problem: it finds files that belong to a package, wherever and however that package is installed. This guide shows how to lay out data files, load them with importlib.resources, make sure the build includes them, and test that the installed package really contains them. It belongs to the packaging Python CLIs for distribution topic.
Prerequisites
- Python 3.10+ (the
files()API used here is available from 3.9 and complete from 3.12;importlib_resourcesbackports newer behaviour). - A CLI in a
src/layout with a build backend such as hatchling or uv_build.
Why __file__ is the wrong tool
Path(__file__).parent / "templates" assumes the package is a directory of real files on disk. That is true for a normal installation and for your checkout, which is why the approach survives until it meets one of the other ways Python code is run: a zipapp built with shiv or zipapp, where modules live inside an archive; a PyInstaller or Nuitka binary, where the layout is decided by the freezer; or an unusual importer. os.getcwd()-relative paths are worse still — they depend on where the user happens to be standing. And the old pkg_resources API is deprecated and slow to import.
importlib.resources.files(package) asks the package's own loader for its resources. Normal packages, zip imports and well-behaved freezers all answer correctly.
The recipe: layout and loading
Put data files inside the importable package, so they travel with it:
src/mytool/
├── __init__.py
├── cli.py
├── resources.py
├── schema.json
└── templates/
├── __init__.py # optional since 3.10; keeps older tooling happy
├── default.toml
└── ci.yml.j2
Then load them through a small module, so the rest of the code never deals with paths:
# src/mytool/resources.py
from __future__ import annotations
import json
from collections.abc import Iterator
from contextlib import contextmanager
from importlib.resources import as_file, files
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: # the abc module moved in 3.11; only needed for typing
from importlib.resources.abc import Traversable
PACKAGE = "mytool"
def _root() -> Traversable:
return files(PACKAGE)
def template_names() -> list[str]:
return sorted(t.name for t in (_root() / "templates").iterdir()
if t.is_file() and not t.name.startswith("__"))
def read_template(name: str) -> str:
resource = _root() / "templates" / name
if not resource.is_file():
raise FileNotFoundError(f"no template named {name!r}; available: {', '.join(template_names())}")
return resource.read_text(encoding="utf-8")
def load_schema() -> dict[str, Any]:
return json.loads((_root() / "schema.json").read_text(encoding="utf-8"))
@contextmanager
def template_path(name: str) -> Iterator[Path]:
"""A real filesystem path to a template, for tools that insist on one."""
with as_file(_root() / "templates" / name) as path:
yield path
files() returns a Traversable — an object with /, iterdir(), is_file(), read_text() and read_bytes(), which behaves like a Path without promising to be one. For most uses, reading the contents is all you need. When something outside Python requires a real path — passing a template to an external program, for example — as_file() gives you one: for a normal install it is simply the existing file, and for a package inside a zip it extracts to a temporary file and cleans up when the block exits.
Using it from a command:
# src/mytool/cli.py
from pathlib import Path
from typing import Annotated
import typer
from mytool import resources
app = typer.Typer()
@app.callback()
def main() -> None:
"""Project scaffolding."""
@app.command()
def init(
template: Annotated[str, typer.Option(help="Template to use.")] = "default.toml",
dest: Annotated[Path, typer.Option(dir_okay=False)] = Path("mytool.toml"),
) -> None:
"""Write a starter configuration file."""
if dest.exists():
typer.echo(f"error: {dest} already exists", err=True)
raise typer.Exit(1)
try:
dest.write_text(resources.read_template(template), encoding="utf-8")
except FileNotFoundError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(2)
typer.echo(f"wrote {dest}", err=True)
@app.command("templates")
def list_templates() -> None:
"""List bundled templates."""
for name in resources.template_names():
typer.echo(name)
Making sure the build includes the files
The loading code is only half the job; the files must also be in the wheel. Build backends differ in what they include by default:
With hatchling and uv_build, files inside the package directory are included in the wheel by default, so the layout above just works. With setuptools, package data inclusion depends on configuration ([tool.setuptools.package-data]) and whether files are tracked by version control with a plugin — the source of many "worked locally, missing in the wheel" bugs. Whatever the backend, verify rather than trust:
uv build
unzip -l dist/mytool-*.whl | grep -E 'templates/|schema.json'
If you need to exclude files — test fixtures, editor backups — use the backend's exclude settings, such as [tool.hatch.build.targets.wheel] exclude = ["**/*.orig"].
Resources that change with the tool
Bundled files are versioned with the code, which is usually exactly what you want: the schema that validates a config matches the code that reads it, and a template generated by version 2.4 is the one version 2.4 was tested with. Two practices keep that property useful. Stamp generated output with the tool version — a comment such as # generated by mytool 2.4.0 at the top of a file written from a template — so that when a user reports a problem with a generated file, you know which template produced it. And treat bundled schemas as part of the public contract: if users validate their own files against mytool schema --print, changing the schema is a compatibility question, handled under the policy in semantic versioning policy for CLI tools. A small schema command that prints the bundled schema also lets editors and CI pipelines validate configuration without installing anything beyond the CLI itself.
UX considerations
- List what is available. A
templatescommand (or listing names in an error message, asread_templatedoes) turns "no such template" from a dead end into a choice. - Let users override bundled defaults. A common pattern is to look for a user template in the config directory first and fall back to the bundled one, so teams can customise without forking — see storing app data with platformdirs.
- Never write into the package. Installed packages may be read-only, shared between users, or inside a zip. Generated or cached data belongs in the user's cache or state directory.
- Keep resources small. Everything bundled is downloaded by every user. Large datasets are better fetched on first use and cached.
Testing the behaviour
Unit tests confirm the loader works from the source tree; a check against the built wheel confirms the files were packaged. The second is the one that catches real release bugs:
# tests/test_resources.py
import zipfile
from pathlib import Path
import pytest
from mytool import resources
def test_templates_are_listed():
assert "default.toml" in resources.template_names()
def test_unknown_template_lists_alternatives():
with pytest.raises(FileNotFoundError, match="available: .*default.toml"):
resources.read_template("nope.toml")
def test_schema_loads():
assert resources.load_schema()["type"] == "object"
def test_template_path_is_a_real_file():
with resources.template_path("default.toml") as p:
assert p.is_file()
@pytest.mark.slow
def test_wheel_contains_resources(tmp_path: Path):
import subprocess
subprocess.run(["uv", "build", "--wheel", "--out-dir", str(tmp_path)], check=True,
capture_output=True)
names = zipfile.ZipFile(next(tmp_path.glob("*.whl"))).namelist()
assert "mytool/schema.json" in names
assert "mytool/templates/default.toml" in names
To prove the loader works from a zip as well, add the built wheel (which is a zip) to sys.path in a subprocess and call template_names() — zipimport will load the package straight from the archive, exactly the situation __file__-based code fails in. The general artefact check is in smoke-testing the built wheel in CI.
Conclusion
Data files belong inside your package, loaded through importlib.resources.files() rather than __file__ or the working directory, with as_file() for the rare case an external program needs a real path. Wrap access in one small module, list available resources in errors, never write into the package, and verify with a test against the built wheel that the files are actually there. The same code then works from a checkout, a wheel, a zipapp and a frozen binary.
Frequently asked questions
Do resource directories need an __init__.py?
Not for files() on Python 3.10+, which can traverse plain subdirectories of a package. Adding one does no harm and helps some older build tools include the directory.
How do PyInstaller and Nuitka handle resources?
Both need to be told to include data files (--add-data or --collect-data mytool for PyInstaller, --include-package-data=mytool for Nuitka). Once included, importlib.resources finds them. See bundling a Python CLI with PyInstaller.
What about Jinja templates?
Jinja's PackageLoader("mytool", "templates") uses the same resource machinery and works in wheels and zips. Prefer it over FileSystemLoader pointed at a path computed from __file__.
Can I ship shell completion scripts this way?
Yes — bundle pre-generated completion scripts as resources and add a completion install command that copies them to the right place for each shell, as covered in installing shell completion for bash, zsh and fish.