python-packaging-hatchling-gitignore-wheel
Root cause. Hatchling merges .gitignore lines into the wheel's exclude_spec (a pathspec.GitIgnoreSpec, built in hatchling/builders/config.py:exclude_spec / load_vcs_exclusion_patterns) and matches them against paths relative to the project root. An unanchored _*.py (no leading /, no inner /) is a basename pattern that matches at any depth — and __init__.py begins with _, so both h3_harness/__init__.py and examples/__init__.py were silently dropped from every wheel. The installed package became an implicit namespace package (h3_harness.__file__ is None) and from h3_harness import BaseHarness raised ImportError. make build imports from the source tree, so it could never catch this.
The fix — anchor the pattern (.gitignore, one-line diff):
__pycache__/
*.py[cod]
dist/
build/
.venv/
-_*.py
+/_*.py
/_*.py matches only at the repo root (still excluding the root-level _build.py helper) and no longer touches __init__.py anywhere.
The guard — a CI step that inspects the built artifact. New scripts/check_wheel.py (stdlib zipfile only, zero deps):
#!/usr/bin/env python3
"""CI check: every built wheel must contain the required __init__.py files."""
import glob, os, sys, zipfile
REQUIRED_INIT_FILES = ("h3_harness/__init__.py", "examples/__init__.py")
def check_wheel(wheel_path: str) -> list[str]:
with zipfile.ZipFile(wheel_path) as zf:
names = set(zf.namelist())
return [rel for rel in REQUIRED_INIT_FILES if rel not in names]
def main() -> int:
wheels = sorted(glob.glob(os.path.join("dist", "*.whl")))
if not wheels:
print("check_wheel: no wheel found in dist/", file=sys.stderr)
return 1
failed = False
for wheel in wheels:
missing = check_wheel(wheel)
if missing:
failed = True
print(f"FAIL {os.path.basename(wheel)}: missing from wheel -> {missing}", file=sys.stderr)
else:
print(f"OK {os.path.basename(wheel)}: all required __init__.py present")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
Wired into .github/workflows/ci.yml (build → zipfile check → non-editable install smoke test) and a local make check-wheel:
- name: Build wheel
run: |
python -m pip install --upgrade pip build
python -m build --wheel
- name: Verify wheel contains required __init__.py files (stdlib zipfile)
run: python scripts/check_wheel.py
- name: Smoke-test the installed wheel (non-editable)
run: |
python -m pip install --force-reinstall --no-deps dist/*.whl
python -c "from h3_harness import BaseHarness; print(BaseHarness().run())"
PYTHON ?= python3
build: $(PYTHON) -m build --wheel
check-wheel: $(PYTHON) scripts/check_wheel.py
Verified end-to-end in `~/work/h3-harness` (hatchling 1.31.0, Python 3.14, git repo present). **Before (commit `a331f8a`, unanchored `_*.py`):** - `pip wheel . --no-deps` → wheel `h3_harness-0.1.0-py3-none-any.whl`, 6 files: `h3_harness/core.py`, `examples/quickstart.py` + dist-info. `unzip -l` shows **no `__init__.py` anywhere** (also dropped `h3_harness/_helpers.py`). - Non-editable install (`pip install dist/*.whl`, run from `/tmp`): `h3_harness.__file__` → `None` (namespace package), `from h3_harness import BaseHarness` → `ImportError: cannot import name 'BaseHarness' from 'h3_harness' (unknown location)`. - `python scripts/check_wheel.py` → `FAIL ... missing from wheel -> ['h3_harness/__init__.py', 'examples/__init__.py']`, exit 1. **After (commit `ff31fb5`, anchored `/_*.py`):** - Fresh wheel, 9 files: `h3_harness/__init__.py`, `h3_harness/core.py`, `h3_harness/_helpers.py`, `examples/__init__.py`, `examples/quickstart.py` + dist-info. `unzip -l` confirms both `__init__.py` files present. - `scripts/check_wheel.py` → `OK`, exit 0. - Fresh venv non-editable install → `h3_harness.__file__` is the real `site-packages/h3_harness/__init__.py`; `from h3_harness import BaseHarness; BaseHarness().run()` → `ok`. - Exact repro command `pip wheel . --no-deps` also yields a complete wheel; `make build` + `make check-wheel` pass; sdist `tar tzf` contains both `__init__.py` files; empty `dist/` → check exits 1; `ci.yml` parses (valid YAML, job `build-and-verify-wheel`). **Pathspec semantics (same library hatchling uses):** | pattern | ignores | |---|---| | `_*.py` | `h3_harness/__init__.py`, `examples/__init__.py`, `h3_harness/_helpers.py`, `_build.py` | | `/_*.py` | `_build.py` only | **Edge cases tested:** 1. Root-level `_*.py` (`_build.py`) is still excluded after the fix — `git check-ignore -v` shows `.gitignore:6:/_*.py _build.py` and it's absent from the wheel/sdist. Git and hatchling now agree. 2. Nested `_*.py` (`h3_harness/_helpers.py`) now **ships** — an intentional behavior change of anchoring; it's self-contained and imports fine from the installed wheel. If nested private modules should stay out, use `**/_*.py` plus a negation (`!_*/__init__.py`) or an explicit `[tool.hatch.build] exclude` list instead. 3. `__init__.py` is the only underscore-file type affected — `_build.py` vs `__init__.py` distinction is exactly the root/basename semantics that the leading `/` controls. 4. The check fails loudly (exit 1) both on a bad wheel and on a missing wheel, so a broken `make build` can't silently pass CI. 5. Both wheel and sdist verified, and the installed (non-editable) artifact — not the source tree — is what the CI smoke test imports from.
{"model": "deepseek-v4-flash", "problem_class": "python-packaging-hatchling-gitignore-wheel", "result": "passed", "tests": 9}