Skip to content

Commit 96b1785

Browse files
fix(load_module): re-execute when cached __file__ differs from requested path
CI hang on `tests/test_cli/test_run.py::TestRunFailsWhenConnectionClassNotPresent::test_run` diagnosed after every platform_checks matrix run on this branch hit the 120-min workflow timeout cancel. Symptom: the test overwrites a connection's `connection.py` with a stub that lacks `HTTPClientConnection`, runs the agent via the CLI, and expects `ClickException("Connection class 'HTTPClientConnection' not found")`. After commit `a0d759e7c` (drop __file__ equality from cache-hit check), `load_module` returns the cached real connection from `sys.modules` regardless of which file the caller pointed it at. The class IS found (from the cache), the agent boots, enters its asyncio event loop, and runs forever. Root cause: the `a0d759e7c` relaxation was too broad. It addressed the "Found many classes with name 'EchoBehaviour'" CI failure by always reusing the cache, but in doing so it silently bypasses any caller that points `load_module` at a different physical file — which is exactly what test fixtures that stub source files on disk do. Fix: restore the `__file__` equality guard. Cache-hit only when the cached module's `__file__` resolves to the same path as the requested `filepath`. When paths differ, fall through to spec-from-file-location and re-execute against the file the caller pointed us at. The block-import sentinel (`sys.modules[k] is None`) still raises `ImportError` as before. On exec failure the half-built stub is popped — a stale prior entry (different `__file__`) is discarded rather than restored, since the caller was asking us to load a different file. Tests: - `test_load_module_reuses_cached_module_for_same_dotted_path` renamed to `test_load_module_reuses_cached_module_for_same_file` and rewritten: the "different file at the same dotted path" branch now asserts the cache is bypassed and the new source is executed (Other defined, Marker not), instead of the old "still reuse the cached module" assertion. Verified locally: - `pytest tests/test_cli/test_run.py::test_run[None] tests/test_cli/test_run.py::TestRunFailsWhenConnectionClassNotPresent::test_run` passes in 1.4s (was hanging at 120s+). - `pytest tests/test_skills/test_base.py tests/test_contracts/test_base.py tests/test_connections/test_base.py`: 217 passed, 1 skipped, 1 error (Docker-related, unrelated). - `pytest tests/test_cli/test_run.py tests/test_configurations/`: 377 passed, 5 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 56ec6f2 commit 96b1785

2 files changed

Lines changed: 45 additions & 39 deletions

File tree

aea/helpers/base.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -127,31 +127,37 @@ def load_module(dotted_path: str, filepath: Path) -> types.ModuleType:
127127
:raises ValueError: if the filepath provided is not a module. # noqa: DAR402
128128
:raises Exception: if the execution of the module raises exception. # noqa: DAR402
129129
"""
130-
# If the dotted path is already in ``sys.modules``, reuse it instead
131-
# of re-executing the file. Re-execution creates a duplicate copy of
132-
# every class defined in the source, breaking class identity for
133-
# callers that already hold a reference (e.g. via an earlier
134-
# ``from ... import X``). ``__file__`` is intentionally NOT compared
135-
# — in test fixtures the same logical package may be reached
136-
# through different physical files (vendor copy in a tmpdir vs the
137-
# source tree) but both register the same dotted path and must
138-
# resolve to the same module object across the whole process. An
139-
# explicit ``None`` entry is CPython's block-import sentinel: raise
130+
# If the dotted path is already in ``sys.modules`` AND the cached
131+
# module's ``__file__`` resolves to the same path we were asked to
132+
# load, reuse it. Reuse preserves class identity for callers that
133+
# already hold a reference (e.g. via an earlier ``from ... import
134+
# X``). When the requested ``filepath`` differs from the cached
135+
# ``__file__`` (a caller replaced the source on disk, or two distinct
136+
# copies of the same package live at different paths), re-execute
137+
# so the caller sees the file they pointed us at. An explicit
138+
# ``None`` entry is CPython's block-import sentinel: raise
140139
# ``ImportError`` to match the standard import semantics rather
141140
# than silently overwriting it with a fresh exec.
142141
if dotted_path in sys.modules:
143142
existing = sys.modules[dotted_path]
144143
if existing is None:
145144
raise ImportError(f"import of {dotted_path!r} halted; None in sys.modules")
146-
return existing
145+
existing_file = getattr(existing, "__file__", None)
146+
if existing_file is not None:
147+
try:
148+
same_file = Path(existing_file).resolve() == Path(filepath).resolve()
149+
except OSError:
150+
same_file = False
151+
if same_file:
152+
return existing
147153
spec = importlib.util.spec_from_file_location(dotted_path, str(filepath))
148154
module = importlib.util.module_from_spec(cast(ModuleSpec, spec))
149155
# Register before exec so a downstream `import` of the same dotted
150-
# path during exec resolves to this module. We only reach this point
151-
# when the key was absent (the cache-hit and block-import branches
152-
# above covered the other two cases), so on exec failure we pop the
153-
# half-built stub rather than restoring a prior entry that, by
154-
# construction, did not exist.
156+
# path during exec resolves to this module. On exec failure pop the
157+
# half-built stub — if a stale entry (different ``__file__``) was
158+
# present it is discarded rather than restored, since the caller
159+
# was asking us to load a different file and we have no reason to
160+
# leave the stale module visible.
155161
sys.modules[dotted_path] = module
156162
try:
157163
spec.loader.exec_module(module) # type: ignore

tests/test_skills/test_base.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -511,17 +511,16 @@ def test_load_module_pops_sys_modules_on_exec_failure(tmp_path):
511511
sys.modules.pop(dotted_path, None)
512512

513513

514-
def test_load_module_reuses_cached_module_for_same_dotted_path(tmp_path):
515-
"""`load_module` reuses any module already cached at the dotted path.
516-
517-
Whether the second call points at the same physical file or a
518-
different one (e.g. a vendor copy in a tmpdir vs the source tree
519-
in test fixtures), `load_module` returns the cached object — never
520-
re-executes. Re-execution would create duplicate class objects
521-
for callers that already hold references to the original
522-
definitions (e.g. via an earlier ``from ... import X``). Mirrors
523-
Python's own import semantics: once registered, the cached module
524-
is canonical for that dotted path.
514+
def test_load_module_reuses_cached_module_for_same_file(tmp_path):
515+
"""`load_module` reuses the cached module when the file matches.
516+
517+
Re-executing the same source would create duplicate class objects
518+
and break class identity for callers that already hold a reference
519+
(e.g. via an earlier ``from ... import X``). When the caller asks
520+
for the same dotted path with a *different* physical file, the
521+
cache is bypassed and the new file is executed — the caller has
522+
explicitly pointed `load_module` at a different source and
523+
expects to see it.
525524
"""
526525
import sys
527526

@@ -538,22 +537,21 @@ def test_load_module_reuses_cached_module_for_same_dotted_path(tmp_path):
538537
first = load_module(dotted_path, same_src)
539538
first_marker = first.Marker # type: ignore[attr-defined]
540539

541-
# Same file → reuse.
540+
# Same file → cache hit, identical module object.
542541
second = load_module(dotted_path, same_src)
543542
assert second is first, "load_module re-executed for the same file"
544543
assert second.Marker is first_marker # type: ignore[attr-defined]
545544

546-
# Different file at the same dotted path → still reuse the
547-
# cached module. ``Marker`` survives; ``Other`` is NOT defined
548-
# on the returned module because the second source was never
549-
# executed.
545+
# Different file at the same dotted path → re-execute. The
546+
# returned module reflects the new source: ``Other`` is
547+
# defined, ``Marker`` is not.
550548
third = load_module(dotted_path, other_src)
551-
assert third is first, (
552-
"load_module re-executed when the cached dotted path was "
553-
"called with a different physical file"
549+
assert third is not first, (
550+
"load_module returned the stale cached module instead of "
551+
"executing the new source"
554552
)
555-
assert hasattr(third, "Marker")
556-
assert not hasattr(third, "Other")
553+
assert hasattr(third, "Other")
554+
assert not hasattr(third, "Marker")
557555
finally:
558556
sys.modules.pop(dotted_path, None)
559557

@@ -1217,7 +1215,8 @@ class TestSkillLoadingWarningMessages(BaseAEATestCase):
12171215
_TEST_BEHAVIOUR_CLASS_NAME = "TestBehaviour"
12181216

12191217
_test_skill_module_path = "skill_module_for_testing.py"
1220-
_test_skill_module_content = dedent(f"""
1218+
_test_skill_module_content = dedent(
1219+
f"""
12211220
from aea.skills.base import Behaviour, Handler
12221221
12231222
class {_TEST_HANDLER_CLASS_NAME}(Handler):
@@ -1241,7 +1240,8 @@ def act(self):
12411240
pass
12421241
def teardown(self):
12431242
pass
1244-
""")
1243+
"""
1244+
)
12451245

12461246
@classmethod
12471247
def setup_class(cls):

0 commit comments

Comments
 (0)