Skip to content

Commit 3b5fdba

Browse files
ci: the drift job checks an installed wheel, not just the source tree (#129)
Closes #103. #116 already covered most of what that issue asked for -- a weekly resolve that ignores `uv.lock`, reporting to the issue tracker, not gating pull requests. What it did not cover is the criterion's other half: importing every subpackage from the *built wheel*. The distinction is the one #101 was: `mcp>=1.2` resolved to 2.0.0 for anyone installing fresh, `mcp.server.fastmcp` had gone, and `grapharc[mcp]` was broken on arrival while every locked job stayed green. A suite run from the source tree does not see a packaging break that only shows in an installed wheel -- a subpackage dropped from the build, or an extra whose new major moves a module the package imports at import time. The walk moves out of `ci.yml`'s heredoc into `scripts/wheel_import_walk.py`, because two jobs now need exactly this check and two copies would drift apart. Same logic, two parameters: which checkout to compare against, and the prefix the import must come from. `scripts/` holds no `grapharc` package, so running it by path does not put the checkout on `sys.path` -- the property the old `cd /tmp` was there for, kept and now asserted with a message rather than a bare `assert`. Verified by running it for real rather than reasoning about it, and it earned its keep immediately: against the wheel then sitting in `dist/` it reported `grapharc.examples.plan_research` missing -- correctly, because that wheel was built before #115 merged. Rebuilt, it walks 132 modules clean. Deliberately **not** done: putting a ceiling on the eight unbounded extras. The issue asks for a decision on that and argues a bound should be "a ceiling with a reason attached, not a reflex" -- so adding eight of them at once is the reflex it warns against, and it would refuse users upgrades that are fine. Detection is what this closes; the policy is left open. Verified: 2214 selected, 13 deselected, ruff clean; the sdist's required-files and secret-leak checks still pass with `scripts/` added. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e1b0dfb commit 3b5fdba

2 files changed

Lines changed: 120 additions & 38 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 25 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,25 @@ jobs:
236236
run: |
237237
uv sync --all-extras --group dev
238238
uv run pytest
239+
- name: The wheel still imports against the re-resolved versions
240+
# The other half of #103. The suite above runs from the source tree, so a
241+
# packaging break that only shows in an *installed* wheel -- a subpackage
242+
# dropped, or an extra whose new major moves a module the package imports
243+
# at import time -- would not surface there. #101 was exactly that shape:
244+
# `mcp>=1.2` resolved to 2.0.0 for anyone installing fresh,
245+
# `mcp.server.fastmcp` had gone, and `grapharc[mcp]` was broken on
246+
# arrival while every locked job stayed green.
247+
#
248+
# Same script the build job runs, pointed at a clean environment built
249+
# from `pyproject.toml`'s ranges rather than from `uv.lock`.
250+
run: |
251+
uv build
252+
uv venv --python 3.12 /tmp/driftcheck
253+
uv pip install --python /tmp/driftcheck/bin/python "$(echo dist/*.whl)[all]"
254+
cd /tmp
255+
/tmp/driftcheck/bin/python "$GITHUB_WORKSPACE/scripts/wheel_import_walk.py" \
256+
--source-tree "$GITHUB_WORKSPACE" --expect-prefix /tmp/driftcheck/
257+
/tmp/driftcheck/bin/grapharc --version
239258
- name: Say so where someone will see it
240259
# A scheduled run reports to nobody. This repository has already paid
241260
# for that: `pages.yml` failed on two consecutive pushes and the
@@ -291,48 +310,16 @@ jobs:
291310
run: uvx twine check --strict dist/*
292311
- name: Wheel installs and imports in a clean environment
293312
# Runs from /tmp so an `import grapharc` cannot fall back to the
294-
# checked-out source tree and pass for the wrong reason.
313+
# checked-out source tree and pass for the wrong reason. The check is
314+
# `scripts/wheel_import_walk.py` rather than a heredoc because
315+
# `upstream-drift` needs exactly the same one against re-resolved
316+
# dependencies, and two copies of it would drift apart.
295317
run: |
296318
uv venv --python 3.12 /tmp/wheelcheck
297319
uv pip install --python /tmp/wheelcheck/bin/python "$(echo dist/*.whl)[all]"
298320
cd /tmp
299-
SOURCE_TREE="$GITHUB_WORKSPACE" /tmp/wheelcheck/bin/python - <<'PY'
300-
import importlib
301-
import os
302-
import pkgutil
303-
import sys
304-
from pathlib import Path
305-
306-
import grapharc
307-
308-
assert "/tmp/wheelcheck/" in grapharc.__file__, grapharc.__file__
309-
from grapharc import Budget, GraphARC, GraphARCState # noqa: F401
310-
from grapharc.gateway import get_model # noqa: F401
311-
from grapharc.harness import Harness # noqa: F401
312-
313-
# Compared against the checkout rather than a magic number. A `walked
314-
# > N` check cannot notice a whole subpackage going missing, and one
315-
# did go missing in testing: hatchling treats `.gitignore` as a build
316-
# exclusion unless `ignore-vcs` is set, and the build still succeeds.
317-
source = Path(os.environ["SOURCE_TREE"]) / "grapharc"
318-
expected = {
319-
".".join(("grapharc", *path.relative_to(source).parts))[: -len(".py")].removesuffix(
320-
".__init__"
321-
)
322-
for path in source.rglob("*.py")
323-
if "__pycache__" not in path.parts
324-
}
325-
installed = {m.name for m in pkgutil.walk_packages(grapharc.__path__, "grapharc.")}
326-
installed.add("grapharc")
327-
328-
missing = sorted(expected - installed)
329-
if missing:
330-
sys.exit(f"in the source tree but not in the wheel: {missing}")
331-
332-
for name in sorted(installed):
333-
importlib.import_module(name)
334-
print(f"ok: {len(installed)} modules imported from wheel {grapharc.__version__}")
335-
PY
321+
/tmp/wheelcheck/bin/python "$GITHUB_WORKSPACE/scripts/wheel_import_walk.py" \
322+
--source-tree "$GITHUB_WORKSPACE" --expect-prefix /tmp/wheelcheck/
336323
/tmp/wheelcheck/bin/grapharc --version
337324
- name: Sdist installs and imports in a clean environment
338325
run: |

‎scripts/wheel_import_walk.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Every module in the source tree is in the installed wheel, and imports.
2+
3+
Run against a *clean* environment that has `grapharc` installed from a built
4+
wheel, from a working directory outside the checkout — otherwise `import
5+
grapharc` can fall back to the source tree and pass for the wrong reason. This
6+
file lives in `scripts/`, which contains no `grapharc` package, so running it by
7+
path does not put the checkout on `sys.path` either.
8+
9+
Two jobs need exactly this check, which is why it is a file rather than a
10+
heredoc:
11+
12+
- `build`, against the dependencies `uv.lock` pins — the shipped artifact is
13+
importable;
14+
- `upstream-drift`, against dependencies re-resolved from `pyproject.toml` with
15+
no ceiling — the shipped artifact is *still* importable once upstream moves.
16+
That is the half issue #103 asked for: `uv.lock` hides a new major from every
17+
other job, because keeping development reproducible is the lockfile's whole
18+
job.
19+
20+
The comparison is against the checkout rather than a magic number. A `walked >
21+
N` check cannot notice a whole subpackage going missing, and one did go missing
22+
in testing: hatchling treats `.gitignore` as a build exclusion unless
23+
`ignore-vcs` is set, and the build still succeeds.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import importlib
30+
import pkgutil
31+
import sys
32+
from pathlib import Path
33+
34+
35+
def main() -> int:
36+
parser = argparse.ArgumentParser(description=__doc__)
37+
parser.add_argument(
38+
"--source-tree",
39+
required=True,
40+
type=Path,
41+
help="the checkout to compare against (the directory holding grapharc/)",
42+
)
43+
parser.add_argument(
44+
"--expect-prefix",
45+
required=True,
46+
help="a path fragment the imported package must come from, e.g. /tmp/wheelcheck/",
47+
)
48+
args = parser.parse_args()
49+
50+
import grapharc
51+
52+
if args.expect_prefix not in grapharc.__file__:
53+
return _fail(
54+
f"imported grapharc from {grapharc.__file__}, which is not under "
55+
f"{args.expect_prefix!r} — the installed wheel is not what was imported"
56+
)
57+
58+
# The public entry points, named explicitly: these are what a reader of the
59+
# README types first, so a wheel that walks cleanly and cannot do these is
60+
# still broken.
61+
from grapharc import Budget, GraphARC, GraphARCState # noqa: F401
62+
from grapharc.gateway import get_model # noqa: F401
63+
from grapharc.harness import Harness # noqa: F401
64+
65+
source = args.source_tree / "grapharc"
66+
if not source.is_dir():
67+
return _fail(f"no grapharc package under {args.source_tree}")
68+
69+
expected = {
70+
".".join(("grapharc", *path.relative_to(source).parts))[: -len(".py")].removesuffix(
71+
".__init__"
72+
)
73+
for path in source.rglob("*.py")
74+
if "__pycache__" not in path.parts
75+
}
76+
installed = {module.name for module in pkgutil.walk_packages(grapharc.__path__, "grapharc.")}
77+
installed.add("grapharc")
78+
79+
missing = sorted(expected - installed)
80+
if missing:
81+
return _fail(f"in the source tree but not in the wheel: {missing}")
82+
83+
for name in sorted(installed):
84+
importlib.import_module(name)
85+
print(f"ok: {len(installed)} modules imported from wheel {grapharc.__version__}")
86+
return 0
87+
88+
89+
def _fail(message: str) -> int:
90+
print(f"error: {message}", file=sys.stderr)
91+
return 1
92+
93+
94+
if __name__ == "__main__":
95+
raise SystemExit(main())

0 commit comments

Comments
 (0)