Skip to content

fix(hooks,tests): pre-push runs changed test_*.py, and a services/ test no longer swaps the real package over the conftest stub (#16711, #16722) - #16728

Closed
mrveiss wants to merge 12 commits into
mainfrom
issue-16711-prepush-selection
Closed

mrveiss wants to merge 12 commits into
mainfrom
issue-16711-prepush-selection

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Closes #16711
Refs #16722, #16715, #16700

Two pre-push problems, fixed together at the owner's direction because both sit in the hook's test selection. #16722 blocks the push of #16020/#16712.

Thinking Path

  • sys.modules leak guard: reconciler_status_restart_delta_14465_test.py leaks 'services' into a later autobot-slm-backend/tests file #16722's leak isn't caused by the test's code. It reproduces under --collect-only, before any test body runs, and a try/finally inside the test didn't help. Reading pytest 9.1.1's _pytest/pathlib.py:_import_module_using_spec explains it. In importlib mode, pytest imports a test module's parent package first, and re-imports that package from disk whenever the object in sys.modules has no __path__. The slm conftest's services stub is a bare MagicMock, so collecting any test module under autobot-slm-backend/services/ ran the real services/__init__.py over the stub.
  • Why only this file pair fails. The sys.modules leak guard blames whichever services/ test is collected first. In CI that's each shard's alphabetically first file, and the baseline already lists it. CI's own leak report on main (run 34811729670) shows sys.modules['services'] was replaced by autobot-slm-backend/services/a2a_card_fetcher_test.py as that owner's single known key. Pre-push's two-file selection made reconciler_* the first, and it isn't on the baseline. Reordering the hook can't fix this, because services/ already sorts first. The same order dependence is latent in CI: a shard rebalance would blame a new file.
  • ci(pre-push): a changed test file named test_*.py never runs in the pre-push hook #16711. The hook recognised tests only by *_test.py, so a changed tests/test_*.py was treated as production code and never ran.

What Changed

Acceptance criteria

Issue AC Evidence
#16711 A changed file matching pytest's python_files (read from pytest.ini) is selected, subject to the ignore filter select() and pytest_settings(); test_the_naming_conventions_come_from_pytest_ini reads the real pytest.ini.
#16711 A changed test_*.py is no longer treated as a production file needing a sibling test_a_changed_test_module_is_not_given_a_sibling
#16711 A hook test pins both naming conventions, with a changed tests/test_x.py selected test_the_16700_changeset_runs_every_test_it_changed replays #16700's changeset: three tests/test_*.py files and a *_test.py.
#16722 The pairing no longer trips the leak guard Not verified here: I can't run tests outside the hooks. The evidence will be #16020/#16712's pre-push on this fix, so this PR only says Refs.

Verification

Model Used

Claude Opus 5 (claude-opus-5).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved service module loading so real service components are imported reliably during testing.
    • Ensured test tooling consistently references the same loaded service modules.
  • Developer Experience

    • Pre-push checks now select relevant tests based on configured naming patterns and changed files.
    • Selected tests display the reason for inclusion.
    • Test timeouts adjust automatically to the number of selected tests, up to 10 minutes.
    • Selection errors now block the push.
  • Tests

    • Added regression coverage for service imports and pre-push test selection.

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • repo_tests/sys_modules_leak_baseline.txt is excluded by !**/*_baseline.txt

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 192072da-1ab0-4116-b679-d22c9f524584

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b39fbfd1-e9c5-46d0-b8dd-7ea516f346f3

📥 Commits

Reviewing files that changed from the base of the PR and between bffbb35 and 9033107.

⛔ Files ignored due to path filters (1)
  • repo_tests/sys_modules_leak_baseline.txt is excluded by !**/*_baseline.txt
📒 Files selected for processing (2)
  • autobot-slm-backend/conftest.py
  • tools/git-hooks/pre-push

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change makes the services stub a hollow package backed by the real directory. It adds regression tests for package imports. It also centralises pre-push pytest selection, supports configured test patterns, reports selection reasons, and scales the test timeout.

Changes

Services package import behaviour

Layer / File(s) Summary
Hollow services package setup
autobot-slm-backend/conftest.py, autobot-slm-backend/tests/services/test_deploy_artifacts_lockstep.py
The services parent is created as a hollow ModuleType package rooted at the real directory. The lockstep test binds the loaded module to the parent package.
Services package regression coverage
autobot-slm-backend/tests/test_services_stub_is_a_package_16722.py
Tests verify package metadata, real submodule imports, pytest setup, parent preservation, and replacement of parent stubs without __path__.

Pre-push pytest selection

Layer / File(s) Summary
Pytest selection engine
tools/lint/prepush_selection.py
The selector reads pytest naming and ignore settings, selects changed and co-located tests, and reports selection reasons.
Pre-push hook integration
tools/git-hooks/pre-push
The hook delegates selection, blocks on selection errors, scales the timeout by selected test count, and logs reasons.
Selection behaviour coverage
tools/lint/prepush_selection_test.py
Tests cover both pytest naming conventions, co-located tests, ignored and missing tests, non-test files, and reason formatting.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Git
  participant prepush
  participant prepush_selection
  participant Pytest
  Git->>prepush: Provide changed paths
  prepush->>prepush_selection: Select applicable tests
  prepush_selection-->>prepush: Return tests and selection reasons
  prepush->>Pytest: Run selected tests with scaled timeout
  Pytest-->>prepush: Return test result
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Issue #16711 concerns pre-push test selection. The changes to autobot-slm-backend/conftest.py, autobot-slm-backend/tests/test_services_stub_is_a_package_16722.py, and `autobot-slm-backend/tests/se… Remove the unrelated SLM services stub and lockstep changes from this pull request, or move them to a separate pull request. Keep the pre-push selection changes and their tests in this pull request.
Docstring Coverage ⚠️ Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 5 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies both main fixes: pre-push selection of changed test_*.py files and the services package stub correction. It is specific and related to the changeset, although it is lon…
Linked Issues check ✅ Passed Issue #16711 requires selection of changed files that match pytest's configured python_files patterns, including test_*.py and *_test.py, while preserving pytest_ignored filtering. `tools/lint…
Full details: Out of Scope Changes check

Explanation

Issue #16711 concerns pre-push test selection. The changes to autobot-slm-backend/conftest.py, autobot-slm-backend/tests/test_services_stub_is_a_package_16722.py, and autobot-slm-backend/tests/services/test_deploy_artifacts_lockstep.py modify and test the SLM services package stub. The linked issue does not require this behaviour, and no connection to its pre-push requirements is shown. The excluded repo_tests/sys_modules_leak_baseline.txt cannot be assessed, but the reviewable services changes are independently unrelated.

Full details: Docstring Coverage

Explanation

Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 5 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

…tes, so Package.setup doesn't read an invented pytest_plugins (#16722)

CI at 9251f0d failed shards 5 and 7 with 59 setup errors across the services/ tests, all "pytest.UsageError: Plugins may be specified as a sequence ... Got: <MagicMock name='mock.pytest_plugins'>".

The cause: now that the stub is kept, Package.setup imports services/__init__.py through importtestmodule, and importlib mode returns the stub already in sys.modules. pytest then reads pytest_plugins (via consider_module) and setUpModule, setup_module, tearDownModule and teardown_module off it, and MagicMock invents all five. The stub now carries what a plain module without them gives: [] and None. The regression test drives importtestmodule itself.
@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Root cause of the python-suite shard 5 and shard 7 reds at 9251f0d91, fixed in 52a3638cb. This is a regression from this PR's own #16722 fix.

Shards 5 and 7 hit 59 setup errors across the autobot-slm-backend/services/ tests (inventory_builder, jwks_verifier, inventory_deploy_groups, slm_unit_refresh). Every one was the same error:

pytest.UsageError: Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: <MagicMock name='mock.pytest_plugins'>

The mechanism, from pytest 9.1.1's source:

  1. The stub now survives collection, which is the point of the sys.modules leak guard: reconciler_status_restart_delta_14465_test.py leaks 'services' into a later autobot-slm-backend/tests file #16722 fix.
  2. Package.setup for services/ imports services/__init__.py through importtestmodule. In importlib mode, import_path returns an existing sys.modules entry unchanged, so it gets the stub back.
  3. pytest then reads package-module attributes off that stub: pytest_plugins (via consider_module), and setUpModule, setup_module, tearDownModule and teardown_module (via _get_first_non_fixture_func).
  4. A MagicMock invents every attribute asked of it, and an invented pytest_plugins is a UsageError.

Before this PR, pytest had already swapped the real package over the stub, so this path never saw a MagicMock.

The fix. The stub now carries exactly those five attributes, with the values a plain module that lacks them would give: [] and None. The new test_pytest_sets_up_the_services_package_with_the_stub_in_place calls importtestmodule on services/__init__.py with the session config, which is the path that errored.

Neither failing shard reported a sys.modules leak failure, which supports the __path__ fix itself. The other shards hadn't finished when this was written.

Pre-push at 52a3638cb passed, but it ran only tools/lint/prepush_selection_test.py. The installed hook still predates #16711, so it did not select the new slm-backend test_*.py, which is exactly the bug #16711 fixes. CI is the check.

@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Review at 52a3638: approve. Full PR (all three commits — #16711, #16722, and the regression fix on top).

#16711, `tools/lint/prepush_selection.py` — clean, testable extraction of what was hand-rolled shell (`existing_py_files`, `pytest_ignored`, the co-located-sibling loop). `pytest_settings()` reads `python_files` from `pytest.ini` directly rather than hardcoding `*_test.py`, fixing the actual #16700 incident (a changed `tests/test_x.py` wasn't recognized as a test at all). Confirmed the module resolves (`tools/init.py` and `tools/lint/init.py` both exist) and #16715 is a real, open, related issue. `test_the_16700_changeset_runs_every_test_it_changed` pins the regression to the actual incident rather than a synthetic case.

Pre-push hook integration — a failed selection now blocks the push outright (`EXIT_CODE=1`, empty selection) rather than silently reading as "nothing to verify," which is the right fail-closed direction for a tool whose whole job is deciding what gets verified.

#16722, the `path` fix — matches the described `_pytest.pathlib._import_module_using_spec` mechanism: a path-less MagicMock stub gets re-imported from disk by pytest's importlib mode on first submodule collection, and the leak baseline's removed `a2a_card_fetcher_test.py` entry checks out as a genuine ratchet shrink (the swap that entry existed for no longer happens).

The regression fix (52a3638) — the part I hadn't seen. Once the stub survives via `path`, `Package.setup` reads `pytest_plugins`/xunit hooks off it and MagicMock invents all five, one of which (`pytest_plugins`) is read as if it were real config and throws `UsageError`. Giving the stub `pytest_plugins = []` and the four hooks `None` is exactly what a plain module without them would present. `test_pytest_sets_up_the_services_package_with_the_stub_in_place` drives `importtestmodule` directly — the real pytest internal, not a reimplementation.

Best part of the test suite: `test_pytest_keeps_a_package_stub_that_has_a_path` / `test_pytest_replaces_a_package_stub_without_a_path` pin the mechanism against real `_pytest.pathlib.import_path` on a scratch package, with the second one explicitly framed as a canary ("if pytest stops doing this, the fix above is no longer needed") — the fix's premise is falsifiable, not assumed.

CI: no FAILURE/ERROR/TIMED_OUT checks. Mergeable.

…k with a __path__ (#16722)

Code review of 52a3638 (HIGH): a MagicMock given a __path__ still invents every attribute. So `from services import manifest_loader`, `jwks_verifier`, `frontend_bundle_health` and the rest bound invented mocks instead of importing the real submodule. And tests/services/conftest.py's _ensure_real_pkg guard now short-circuited on that __path__, so the hollow-package swap it used to do never happened. The package-level UsageError in the last red run hid those tests.

The root conftest now installs a hollow types.ModuleType("services") over the real directory, the shape tests/services/conftest.py already uses, and binds each child stub onto it. It invents nothing, so pytest's Package.setup attribute reads and `from services import` both behave as they would on a real package, and the explicit pytest_plugins and xunit-hook attributes are no longer needed. The regression test checks the shape instead of MagicMock identity: tests/services modules install their own hollow package at import time. It also checks that `from services import purge_playbook`, an unstubbed submodule, loads the real file.
@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

a5ed57ba9: the services stub is now a hollow package. 52a3638cb was still wrong.

My code review of 52a3638cb found a HIGH in the same class of bug: a MagicMock given a __path__ still invents every attribute.

  • from services import manifest_loader, jwks_verifier, frontend_bundle_health and the rest check hasattr(parent, name), get an invented mock, and bind it instead of importing the real submodule.
  • tests/services/conftest.py's _ensure_real_pkg guard short-circuited on that __path__, so the hollow-package swap it used to do never happened.

The package-level UsageError in the previous red run hid all of those tests.

The fix:

  • The root conftest. It installs a hollow types.ModuleType("services") with the real __path__, the same shape tests/services/conftest.py already uses, and binds each child stub onto it. It invents nothing, so pytest's Package.setup reads and from services import x both behave as they would on a real package. The explicit pytest_plugins and hook attributes from 52a3638cb are no longer needed.
  • The regression test. It checks the shape (a ModuleType, not a MagicMock, over the real directory, with nothing invented) rather than object identity, because tests/services modules install their own hollow package at import time.
  • An end-to-end check. The test also asserts that from services import purge_playbook, an unstubbed submodule, loads the real file. Under the MagicMock stub, that import would have bound a mock.

Pre-push at a5ed57ba9: passed on tools/lint/prepush_selection_test.py only. The installed hook predates #16711, so CI is the check. The ledger hold stays until CI is green on this head.

@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Delta review at a5ed57b (from 52a3638): approve.

Verified the HIGH is real, not just plausible: Python's `from pkg import name` first tries `getattr(pkg, name)` before attempting `import pkg.name` from disk. A MagicMock's catch-all `getattr` never raises `AttributeError`, so that first check always "succeeds" with an invented mock — the real-file import is never attempted for any submodule not explicitly bound as a child stub. Adding `path` to the MagicMock (52a3638) fixed the package-detection problem but did nothing about this, since it's a completely different code path.

Confirmed the fix closes it: `from services import purge_playbook` — genuinely unstubbed (not in `conftest.py`'s stub lists, confirmed by grep, and the file exists on disk) — is exactly the right known-positive, and a plain `types.ModuleType` raises real `AttributeError` for it, correctly falling through to the real import.

Confirmed the second half too: `tests/services/conftest.py`'s `_ensure_real_pkg` guard is `if existing is not None and hasattr(existing, "path"): return` — the old MagicMock-with-`path` satisfied this and skipped the hollow-package install that guard exists to perform. The new root-conftest hollow package satisfies the SAME guard for the right reason ("already a package (real or hollow)"), so the two conftests now cooperate instead of one masking the other's work.

Test suite adapted well: shape checks replace identity checks (matches the doc's own point — `tests/services/` installs its own hollow package at import time, so asserting `is` the root conftest's object would be testing the wrong thing), the path comparison now resolves both sides before comparing, and the pytest-keep-vs-replace canary tests were updated to use a hollow `ModuleType` instead of a MagicMock, still pinned against real `_pytest.pathlib.import_path`.

CI: no FAILURE/ERROR/TIMED_OUT checks. Mergeable.

…gisters onto the parent, so the two never diverge (#16722)
@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Root cause of the python-suite shard 12 red at a5ed57ba9, fixed in b3310df4f. It's a latent #9780 divergence in an existing test, exposed here. It isn't a new defect in the stub.

The failure was tests/test_real_service_modules_14307.py::test_the_parent_never_exposes_a_different_object_than_sys_modules: services.deploy_artifacts was bound on the parent as a different module object than sys.modules held. Both objects came from the same file.

Mechanism. At collection time, tests/services/test_deploy_artifacts_lockstep.py loads a fresh copy of deploy_artifacts and registers it with sys.modules["services.deploy_artifacts"] = ..., without binding it onto the parent. Meanwhile the root conftest's "must be REAL" block has already bound its own copy onto the parent. So the parent exposes one object and sys.modules the other, which is exactly what #9780 forbids: patch("services.deploy_artifacts.X") and from services.deploy_artifacts import X resolve to different modules.

Why it showed up now. On main the same sequence happens whenever these two files share a shard. The identity test's own docstring says the outcome depends on which test ran last in the shard. This PR adds a test file, which changes how pytest-split composes the shards, and that put the two files together in shard 12. The other tests that swap services.deploy_artifacts (drift_checker_test, deployed_dir_resolver_test, tests/api/test_drift_resolve*) restore sys.modules afterwards. The lockstep test is the only one that leaves its replacement in place.

Fix. The lockstep test binds the module it registers onto the parent as well, so both views are the same object.

Pre-push at b3310df4f passed, but ran only tools/lint/prepush_selection_test.py, because the installed hook predates #16711. CI is the check, and the ledger hold stays until it is green.

@mrveiss

mrveiss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Delta review at b3310df (from a5ed57b): approve.

Verified against `test_real_service_modules_14307.py`'s own docstring for `test_the_parent_never_exposes_a_different_object_than_sys_modules`: it explicitly asserts "never disagrees" rather than "is always present," specifically to avoid false positives from the two conftests' legitimate stub-swap ordering — so this is a genuine #9780-class divergence bug, not a shard-composition false alarm. The fix is trivially correct: `setattr(sys.modules["services"], "deploy_artifacts", deploy_artifacts)` immediately after `sys.modules["services.deploy_artifacts"] = deploy_artifacts` uses the exact same object reference for both assignments, so `sys.modules["services.deploy_artifacts"] is getattr(sys.modules["services"], "deploy_artifacts")` holds by construction — closing exactly the gap the identity test checks for.

CI: no FAILURE/ERROR/TIMED_OUT checks. Mergeable.

@mrveiss mrveiss added this to the v0.9.0 milestone Sep 17, 2026
@mrveiss

mrveiss commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Carried into vehicle #17119 (member head 9de661fe6 merged server-side, vehicle head 29a976e286). Closing here; acceptance-criteria evidence and CI ride with the vehicle.

@mrveiss mrveiss closed this Sep 19, 2026
mrveiss added a commit that referenced this pull request Sep 19, 2026
…'s selection call (#17008, #16711)

Discovered pushing the vehicle-77 main-merge: this vehicle is the first
time #16728's real `python3 -m tools.lint.prepush_selection` call (added
to the hook's Backend/Python phase) and #17008's synthetic-repo sandbox
test combine, and the sandbox was missing what that call needs --
mktemp/rm (the hook's own error-capture temp file), python3 itself, a
PYTHONPATH pointing at the real repo root (cwd is the synthetic repo,
which has no tools/ package of its own), and a minimal pytest.ini in the
synthetic repo (prepush_selection.py reads cwd/pytest.ini directly).
Nothing here changes what's under test; it lets the real selection tool
run against the synthetic repo's git state instead of dying immediately.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(pre-push): a changed test file named test_*.py never runs in the pre-push hook

1 participant