Have you ever watched a local pytest run stay green, then watched pip install of the same commit explode? I just spent forty-eight hours on that split, staring at a FileNotFoundError for a JSON file committed next to the module. The tests imported the package cleanly, and git ls-files still listed that JSON inside the package directory. So why did the loader fail only after the wheel landed in a completely clean virtualenv?
This writeup is a field notebook rather than a victory lap, because those green laptop tests taught me almost nothing useful. I will record what I tried, what actually broke, and the tiny repro I should have built during hour one. If you ship runtime files beside Python modules, this mismatch is waiting for you.
Hours 0–8: I treated it like a test-path problem
The first traceback pointed at pkg/settings.py, inside a helper I had written years ago without thinking about installers. It resolved Path(__file__).parent / "defaults.json" and called open(), which looks boring until a packaging tool rewrites your layout. I assumed pytest had chosen the wrong rootdir again. Have you made that same assumption just because the failure mentioned a path?
I reran the suite from the repo root, from tests/, and from a nested package directory, hunting for a collection quirk. Every invocation passed on the laptop, including a verbose run that printed Path.cwd() beside Path(__file__). I even forced pytest --import-mode=importlib, because that flag has bitten me on collection order before. None of those reruns moved the failure that only appeared after install.
Commands I actually ran during that first block of hours looked embarrassingly innocent:
pytest -q
pytest -q tests/test_settings.py
python -c "import pkg.settings as s; print(s.__file__)"
git ls-files src/pkg/defaults.json
All four commands agreed that the file existed and that the module imported. That agreement was the trap, because every command was still looking at the checkout. I was debugging a working tree and calling it evidence about the wheel.
Hours 8–24: what broke when I let an assistant rewrite the loader
Around hour nine I pasted the traceback into a coding model, because I wanted another pair of eyes on a problem I was too close to. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access on the free server option for that scratch session, and I treated the output as a draft rather than as packaging authority.
The suggestions were fluent, and they were wrong in a painfully specific way. The model wanted me to copy defaults.json into the working directory at test time, then open the copy. It also offered a sys.path.insert(0, ...) guard, which would have papered over import order without shipping the file to users. Would you have noticed that the proposed fix only helped pytest, and never helped a real pip install?
I tried the copy-to-cwd patch on a branch and watched the pipeline go green, which felt like progress until I installed the wheel. A throwaway virtualenv imported the package the way a user would, and FileNotFoundError came back on the first call. The tests had been cheating by seeing the git tree. The assistant had optimized for the runner I already had, not for the artifact I actually ship.
What broke, once I slowed down and named the layers:
-
defaults.jsonwas never declared as package data, so the wheel omitted it entirely. - After I forced the file into the archive,
Path(__file__).parentstill was not a real directory under zipimport. - Pytest stayed green because it imported the checkout, not the installed distribution.
- Writing into
Path.cwd()is an accident of the test runner, not a loader API.
I had been debugging the test runner for a full working day. I needed to debug the install artifact instead. That sounds obvious in a notebook, and it was not obvious while CI kept oscillating.
The repro I should have built on hour one
The rest of this note is the artifact I will keep on disk. It is a tiny package you can install two ways, plus one test that is honest about which copy it imported. Label: this is a constructed repro, not a dump of a private application repo, so run it yourself before you trust the narrative.
Layout
pkgdata-repro/
pyproject.toml
src/pkg/
__init__.py
settings.py
defaults.json
tests/
test_settings.py
src/pkg/defaults.json stays tiny on purpose:
{"timeout_s": 5, "retries": 2}
The loader that failed me
Leave this version around long enough to watch the wheel break, because the failure is the lesson:
# src/pkg/settings.py
from __future__ import annotations
import json
from pathlib import Path
def load_defaults_via_file() -> dict:
"""Load defaults as if the package always lives on a real filesystem."""
here = Path(__file__).resolve().parent
path = here / "defaults.json"
with path.open(encoding="utf-8") as handle:
return json.load(handle)
The loader I should have written first
# src/pkg/settings.py — replacement
from __future__ import annotations
import json
from importlib.resources import files
def load_defaults() -> dict:
"""Load defaults from package data, including zipped wheels."""
text = files("pkg").joinpath("defaults.json").read_text(encoding="utf-8")
return json.loads(text)
If a subprocess still demands a real filesystem path, do not reach for __file__ and hope. Use the context manager that materializes a resource onto disk for the duration of the with block:
from importlib.resources import as_file, files
def defaults_path():
return as_file(files("pkg").joinpath("defaults.json"))
Callers then write with defaults_path() as path:, which works for zipped wheels and for editable checkouts. That is the whole trick, and I resisted it because Path(__file__) looked simpler.
A test that fails closed in the wheel venv
Editable installs often leave pkg.__file__ pointing at src/pkg in the repository, which is expected. A wheel install should land under site-packages. I now keep those facts in the test module so I cannot mix them up again:
# tests/test_settings.py
from __future__ import annotations
from pathlib import Path
import pkg
from pkg.settings import load_defaults
def test_defaults_timeout_comes_from_package_data() -> None:
data = load_defaults()
assert data["timeout_s"] == 5
def test_import_location_is_explicit(monkeypatch) -> None:
location = Path(pkg.__file__).resolve()
parts = {part.lower() for part in location.parts}
# Set PKGDATA_REQUIRE_WHEEL=1 in the clean venv that ran `pip install .`.
import os
if os.environ.get("PKGDATA_REQUIRE_WHEEL") == "1":
assert "site-packages" in parts, location
assert "src" not in parts, location
else:
assert location.exists()
Packaging declaration
The JSON file does not ride along because it sits next to a .py file. You have to declare it. This sketch uses setuptools because that is what the repo already had:
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pkgdata-repro"
version = "0.1.0"
requires-python = ">=3.10"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
pkg = ["defaults.json"]
Install both ways and compare
This is the method. Notice that pytest is installed as a test tool, not as a fantasy extra I never defined:
python -m venv .venv-src
. .venv-src/bin/activate
python -m pip install -U pip
python -m pip install -e .
python -m pip install pytest
pytest -q
deactivate
python -m venv .venv-wheel
. .venv-wheel/bin/activate
python -m pip install -U pip
python -m pip install .
python -m pip install pytest
PKGDATA_REQUIRE_WHEEL=1 pytest -q
python -c "from pkg.settings import load_defaults_via_file; load_defaults_via_file()"
That last command is the one I postponed for far too long. Do you also wait until CI is red before you import the installed package in a clean virtualenv? The editable venv will usually keep load_defaults_via_file() alive. The wheel venv is where __file__ stops being a directory you can open().
Decision table I will tape above the loader
I needed a boring table more than I needed another clever helper. Here is the one I will actually reuse when a file shows up beside a module.
| How the code runs | Path(__file__) / "defaults.json" |
files("pkg").joinpath(...) |
pytest tmp_path / extra testdata |
|---|---|---|---|
Editable checkout (pip install -e .) |
Usually works | Works | Fine for tests only |
| Installed wheel, data declared | Often fails: not a real directory | Works | Does not ship runtime data |
| Installed wheel, data omitted | FileNotFoundError |
FileNotFoundError, but at the resource API |
Hidden by green checkout tests |
Namespace package without __init__.py
|
__file__ may be missing |
Needs an anchor module | Unrelated |
| JSON that exists only for assertions | Wrong layer | Wrong layer | Correct layer |
If the file is part of the product, it belongs in package data. If the file exists only to feed assertions, it belongs under tests/ and should never be opened through __file__ of a shipped module. Mixing those two jobs is how I lost a day to a green suite.
Hours 24–48: what I would repeat
I would build the two-venv repro before I touch CI YAML, because YAML cannot compensate for an omitted resource. I would print pkg.__file__ and refuse to trust a green suite whose import path still contains the repository's src directory when I meant to test a wheel. I would declare package data in pyproject.toml in the same commit that adds the JSON file, not in a follow-up “packaging cleanup” branch.
I would also keep the assistant on a short leash after that session. The free model pass was useful for reading a traceback out loud and for drafting the importlib.resources version of a twenty-line loader. It was not useful for deciding whether a file is runtime data or test data. That distinction is a product decision, and a model will happily collapse it into whichever patch makes pytest green.
Checklist I will actually reuse:
- Add the data file and the
package-datadeclaration in the same commit. - Open a clean virtualenv and run
pip install .with no editable flag. - Set an explicit env flag so at least one test asserts the wheel landed in
site-packages. - Load runtime files through
importlib.resources.files, notPath(__file__). - Keep pytest
tmp_pathfor fixtures that must never ship inside the wheel.
Limitations, and who should not copy this
This workflow assumes a regular package with an __init__.py and a wheel build that understands package-data. It does not replace an audit of native extensions, data files parked outside the package, or applications that intentionally read /etc and user config directories. I am not claiming a benchmark, a quota, a model ranking, or a forever-free plan. I am claiming that two virtualenvs beat another eight hours of green local tests.
Do not use this approach if your “data file” is a secret, a machine-local path, or a fixture that should never leave the test tree. Do not use it if you ship a single-file script with no package at all, because importlib.resources needs a package anchor. Frozen binaries such as PyInstaller bundles have their own resource APIs, and this notebook does not cover them.
If you already load config from environment variables and remote stores, do not drag those settings into package data just to satisfy a table. Package data is for files you are willing to version and install next to code. Would I start with the wheel venv earlier next time? Yes. Would I let a model rewrite the loader before that venv existed? Not again.
Top comments (0)