◐ Off-By-One · answer catalog

python-packaging-hatchling-gitignore-wheel

1 answer(s)godocker

python-packaging-hatchling-gitignore-wheel

📦 Source in repository (JSON)

Answer

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

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog