fix(datasets): attribute subpackage exports per module, matching tasks/ - #54
Merged
Conversation
The generated `.pyi` and the runtime lazy registry both come from their own AST scan of the package (the stub generator must not import the package it generates stubs for), so the two can drift. Two guards already exist and both miss the same half: - `test_stub_exports_match_runtime_exports` compares only export *names*, which each side renders from its own scan's keys. - preflight's `check_tasks` / `check_datasets` build their fqn from the runtime map alone, so they never consult the stub. A stub that attributes an export to the wrong module therefore ships silently, and ty resolves `from sieval.datasets import X` through a module that does not define `X` while runtime resolution stays correct. Compare the stub's per-name `from .mod import X` targets against `_EXPORT_TO_MODULE`. Verified discriminating: repointing one stub import line at a sibling module — leaving the name set untouched — fails only the new test, while the existing `__all__` test still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ethan-scitix
force-pushed
the
fix/stub-module-attribution-test
branch
from
July 30, 2026 07:34
373ff41 to
6818306
Compare
Two registries resolve subpackage exports, and all three sites disagreed: - `sieval/tasks/__init__.py` (runtime) records "subpkg.module_stem". - `sync_package_stubs.py:99` (top-level task stub) recorded the bare subpackage name — a real divergence from the line above, latent only because no task subpackage is currently registered. The first one would have emitted `from .subpkg import X` while runtime imports `sieval.tasks.subpkg.<module>`. - `sieval/datasets/__init__.py` + `sync_package_stubs.py:169` agreed with each other, but on the bare-subpackage form — the opposite convention. Settle on "subpkg.module_stem" everywhere, which is the one the documented rule requires: `sieval/tasks/CLAUDE.md` mandates an *empty* subpackage `__init__.py`, and an empty `__init__` cannot re-export anything, so the registry has to resolve to the defining module. The dataset side had no documented subpackage convention at all, which is how the divergence got in with the first real dataset subpackage; add one. This also closes a silent-overwrite hole. The duplicate-export guard only fires when the recorded module differs, so attributing every module in a subpackage to the bare subpackage name made two same-named exports collide without error. Verified: a second module in `datasets/ruler/` exporting `RulerDataset` now raises "Duplicate dataset export 'RulerDataset' found in 'ruler.ruler' and 'ruler.ztmp_dup'"; under the old form it overwrote silently. `sieval/datasets/ruler/__init__.py` keeps its re-exports — `tasks/ ruler_0shot_gen.py` imports `len_tag`/`thinking_prefill` from the package, and those carry no registry-visible suffix. What changes is that the registry no longer depends on them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task subpackages get their own `__init__.pyi`; dataset subpackages do not, and that asymmetry looked like an oversight. It is load-bearing — a generated stub *shadows* the real `__init__.py` instead of supplementing it, so adding the symmetric loop breaks type checking two independent ways (both reproduced): - `datasets/ruler/__init__.py` re-exports 5 of its 7 names from the private `_shared.py`, which suffix-based discovery cannot see. The generated stub lists only the 2 class exports, so `len_tag`/`thinking_prefill` become unresolved imports at `tasks/ruler_0shot_gen.py:39`. - `datasets/downloaders/` is infrastructure, not a benchmark, and none of its API carries a dataset suffix. The generated stub comes out `__all__ = []`, hiding `resolve()` and breaking `cli/_readiness.py` + `cli/dataset/commands.py`. `_iter_subpackage_dirs` cannot distinguish either case from a benchmark subpackage, so the rule is documented rather than automated. Also note the task-side loop is not a model to copy: under the empty- `__init__.py` rule in `sieval/tasks/CLAUDE.md`, the stub it writes promises names the empty `__init__` never binds — verified with a scratch subpackage, `sieval.tasks.<sub>.XTask` type-checks but raises AttributeError, while the top-level `sieval.tasks.XTask` resolves correctly. Left in place rather than deleted: it is inert today, but benchmark task subpackages are expected back, and their `__init__.py` contents are not visible from this tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rationale belongs in the commit messages and PR body, which already carry it. Keep only what a reader cannot infer from the code, stated once: 72 lines of comment/docstring down to 26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…errors `_discover_task_classes` / `_discover_dataset_classes` guarded duplicates using bare module stems, so a same-subpackage collision read "found in 'a_0shot_gen' and 'b_0shot_gen'" while the runtime registry reported the qualified "ztmpsub.a_0shot_gen". Take an optional `prefix` and build the qualified name inside the helper instead of re-wrapping it at the two call sites: the messages now match the runtime byte-for-byte, and the qualified name is produced in one place rather than three. Behaviour-preserving — `--check` confirms the generated stubs are byte-identical, and `discover_subpackage_tasks` still passes no prefix, so per-subpackage stubs keep their bare stems. Also restore the caveat the previous trim cut from the task-subpackage loop: `sieval/tasks/CLAUDE.md` mandates an empty subpackage `__init__.py`, so that stub promises names the `__init__` never binds — `sieval.tasks.<sub>.XTask` type-checks but raises AttributeError. That is not inferable from the code and is a trap for the first task subpackage to land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trim cut the one clause explaining why `scripts/sync_package_stubs.py`
carries a second copy of this AST scan: the stub generator must not import the
package it generates stubs for. What replaced it ("must agree") states the
coupling but not the constraint, leaving two copies of the same scanner —
including a duplicated suffix tuple — reading as an obvious dedup target.
Move it back into `_discover_dataset_exports`'s docstring and drop the now
redundant trailing sentence from the subpackage comment, so it is still stated
once.
Also fix `_read_stub_import_map`'s docstring: it maps the names the stub
*imports*, not the ones it exports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note said `sieval/tasks/CLAUDE.md` *mandates* an empty subpackage `__init__.py`. The source does not carry that weight: "with an empty `__init__.py`" is a subordinate clause in a bullet about the ≥ 5-file threshold, there is no MUST and no prohibition on re-exporting, and nothing enforces it — no preflight check, no test, and zero task subpackages currently exist to demonstrate it. The datasets side does the opposite, which this PR just documented as correct there. State it as the convention it is, and say what actually follows: the generated stub is wrong for the documented default layout and correct only for a subpackage that re-exports. That is the sharper version of the caveat — the stub silently requires deviating from the convention in order to be accurate. Cite the file without a line number; a pinned line into a markdown file drifts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only a lazy package has a runtime export map for a stub to mirror. A subpackage is an ordinary package, so the generated `__init__.pyi` described nothing real — and because a stub *shadows* the real `__init__.py` instead of supplementing it, it became the authoritative type surface while contradicting runtime: with the empty subpackage `__init__.py` that `sieval/tasks/CLAUDE.md` prescribes, `sieval.tasks.<sub>.XTask` type-checks but raises AttributeError. This is the same failure the dataset side already avoids by not generating them, so drop the loop rather than keep documenting the trap. Deleting it also removes the caveat comment added earlier in this branch, and leaves `discover_subpackage_tasks` with no callers. Latent, not live: zero task subpackages exist, so no stub was ever emitted and none needed deleting from disk. Verified `--check` still passes and the two top-level stubs are unchanged. Also record *why* a task subpackage's `__init__.py` is empty, which was stated without a reason and so read as an oversight next to the dataset side: nothing imports a task class by name (the registry resolves it through the top-level package), so the subpackage needs no type surface, and staying empty keeps exactly one import path per task. Datasets differ because tasks import them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`datasets/CLAUDE.md` says to re-export only what other packages import directly. `RulerTaskSpec` and `ruler_task` are imported by nothing outside `datasets/ruler/` — not by a task, not by the CLI, not by a test, and not from a non-Python file. Inside the subpackage every user already reaches them via `from ._shared import ...`, which is unaffected. Neither carries a registry-visible suffix, so the lazy export map and both generated stubs are unchanged (`--check` confirms). Also sort `__all__`, which had `tokens_to_generate` ahead of `thinking_prefill`. Left in place for now, though also unused by production: `RulerDataset` (only `tests/unit/datasets/test_ruler.py` takes the subpackage path; production reads it off the top-level registry) and `tokens_to_generate` (test-only, and the other two ruler test modules already import it from `._shared` instead). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onsumer What is left after the previous commit is still wider than the rule in `datasets/CLAUDE.md` allows. Neither remaining extra serves production: - `RulerDataset` is read off the top-level registry everywhere; only `tests/unit/datasets/test_ruler.py` took the subpackage path. Keeping it also offered a second import path beside `sieval.datasets.RulerDataset`. - `tokens_to_generate` is test-only, and the other two ruler test modules already import it from `._shared` — this file was the odd one out. Point both at the defining module, which is what the sibling test modules do (`test_ruler_0shot_gen.py` imports from `._shared` and `.ruler.ruler`) and what the tests carve-out in the root CLAUDE.md permits. `__init__.py` is now exactly the three names `tasks/ruler_0shot_gen.py` imports. This trim is only safe because of the attribution fix earlier in this branch: the registry resolves `RulerDataset` through `ruler.ruler`, so it no longer depends on the package re-exporting it. Verified the counterfactual — replaying the old bare-subpackage attribution against this tree raises "module 'sieval.datasets.ruler' has no attribute 'RulerDataset'", so the same trim would have broken the registry before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type
pin it with the test that would have caught it
Summary
Three sites resolve subpackage exports for the lazy registries, and they did
not agree:
sieval/tasks/__init__.py:76(runtime)subpkg.module_stemscripts/sync_package_stubs.py:99(top-level task stub)subpkgsieval/datasets/__init__.py+sync_package_stubs.py:169subpkgRows 1 and 2 are a real divergence within the task side — latent only
because no task subpackage is currently registered. The first one to land
would have put
from .subpkg import Xin the stub while runtime importssieval.tasks.subpkg.<module>.Settle on
subpkg.module_stemeverywhere. That is the form the documentedrule requires:
sieval/tasks/CLAUDE.mdmandates an empty subpackage__init__.py, and an empty__init__cannot re-export anything, so theregistry must resolve to the defining module. The dataset side had no
documented subpackage convention, which is how the opposite form arrived with
the first real dataset subpackage (
datasets/ruler/, #11) — so this adds one.Two concrete bugs fixed:
recorded module differs, so attributing every module in a subpackage to the
bare subpackage name let two same-named exports collide with no error.
test_stub_exports_match_runtime_exportscompares only export names (each side renders those from its own scan's
keys), and preflight's
check_tasks/check_datasetsbuild their fqn fromthe runtime map alone — so neither ever compares the stub's opinion. A
stub attributing an export to the wrong module shipped silently, and
tywould resolve
from sieval.datasets import Xthrough a module that does notdefine
X.Why neither package generates per-subpackage
__init__.pyiOnly a lazy package has a runtime export map for a stub to mirror. A subpackage is
an ordinary package, so a generated stub describes nothing real — and because a stub
shadows the real
__init__.pyinstead of supplementing it, it becomes theauthoritative type surface while contradicting runtime. On the dataset side that
breaks two independent ways (both reproduced):
datasets/ruler/__init__.pyre-exports from the private_shared.py, invisibleto suffix-based discovery. The generated stub lists only the class exports →
len_tag/thinking_prefillbecome unresolved imports attasks/ruler_0shot_gen.py:39.datasets/downloaders/is infrastructure, not a benchmark, and none of its APIcarries a dataset suffix. The generated stub comes out
__all__ = [], hidingresolve()→ breakscli/_readiness.pyandcli/dataset/commands.py.The task side had the same defect in the opposite direction, so the loop that
generated those stubs is now deleted rather than documented. Under the empty
subpackage
__init__.pythatsieval/tasks/CLAUDE.mdprescribes, the stub promisednames the
__init__never binds — verified with a scratch subpackage,sieval.tasks.<sub>.XTasktype-checks but raises AttributeError while top-levelsieval.tasks.XTaskresolves correctly. It was inert (zero task subpackages exist,so no stub was ever emitted and none needed deleting from disk) but armed for the
first one to land.
discover_subpackage_tasks, which had no other caller, went with it.sieval/tasks/CLAUDE.mdnow also records why that__init__.pyis empty, whichwas stated without a reason and so read as an oversight next to the dataset side:
nothing imports a task class by name — the registry resolves it through the
top-level package — so a task subpackage needs no type surface of its own, and
staying empty keeps exactly one import path per task. Datasets differ because tasks
import them directly (
sieval.datasetsis imported 50× in production, 39 of thosefrom
sieval/tasks/; there is nofrom sieval.tasks import Xanywhere in production).datasets/ruler/re-exports narrowed to threeSince the registry no longer depends on them, the 7 names were audited against real
consumers and 4 dropped:
datasets/ruler/RulerTaskSpec,ruler_taskRulerDatasettests/unit/datasets/test_ruler.pytook the subpackage pathtokens_to_generate._sharedWhat remains is exactly the three names
tasks/ruler_0shot_gen.py:39imports:RulerDatasetSample,len_tag,thinking_prefill. Two test imports were pointedat the defining module, matching what the sibling ruler test modules already do.
This trim is only safe because of the attribution fix above: replaying the old
bare-subpackage attribution against the trimmed tree raises
module 'sieval.datasets.ruler' has no attribute 'RulerDataset', so the same trim wouldhave broken the registry before this PR.
Related Issues
Test Plan
Automated
ruff check && ruff format --check)ty check sieval scripts tests→ All checks passed)pdm run pytest tests/unit -m "not stress"→2662 passed
sync_package_stubs.py --checkandsync_meta_index.py --checkcleancheck_preflight.py→ 19 PASS, 0 FAIL/WARN. Meaningful here:check_datasetsnow buildssieval.datasets.ruler.rulerfrom the new mapand
getattrs the class, so the dotted fqn is exercised end-to-end.Manual
from .ruler.ruler import (...); runtime map reads{'RulerDataset': 'ruler.ruler', 'RulerDatasetSample': 'ruler.ruler'};sieval.datasets.RulerDataset/RulerDatasetSampleboth resolve.line while leaving the exported name set untouched fails only the new
test, with
test_stub_exports_match_runtime_exportsstill passing. Verifiedboth for a flat module (
.aime_2024→.aime_2025) and for the subpackage(
.ruler→.ruler.ruler) — the latter is exactly the half-migrated statethis PR could have produced.
datasets/ruler/exportingRulerDataset: now raisesDuplicate dataset export 'RulerDataset' found in 'ruler.ruler' and 'ruler.ztmp_dup'. Replaying the old bare-subpackage logic on the same treeoverwrites silently. Temp module removed.
sync_package_stubs.py --checkstillpasses and both top-level stubs are byte-identical;
find sieval -name __init__.pyireturns only the two top-level files, before and after.scratch subpackage exporting the same name: generator and runtime both report
found in 'ztmpsub.a_0shot_gen' and 'ztmpsub.b_0shot_gen'(the generator usedto print bare stems).
discover_subpackage_tasks' bare-stem contract waspreserved until its removal.
Checklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)inmodule docstring — extends existing marked files, markers unchanged
core/discover_subpackage_tasksand the per-subpackagestub loop are deleted; grepped for dangling references (none in
sieval/,scripts/,tests/,docs/,.pre-commit-config.yaml) and confirmed nosubpackage
__init__.pyiexisted on disk to clean up. The four droppeddatasets/ruler/re-exports were each checked for consumers first.If: Breaking Change
_EXPORT_TO_MODULEand the generated.pyiareinternal to lazy loading. Public access (
from sieval.datasets import RulerDataset) is unchanged, and both packages'__all__are byte-identicalbefore and after.
tests/unit/datasets/test_ruler.pynow takesRulerDatasetfrom.ruler.rulerandtokens_to_generatefrom._shared. No assertion changed;2662 passed throughout, which is the point of the new test.
🤖 Generated with Claude Code