Skip to content

Commit 277a31c

Browse files
revert(load_module): restore loose cache-hit; rewrite stub-based test as subprocess
CI on `platform_checks (ubuntu-latest, 3.12)` surfaced the "Found many classes with name 'EchoBehaviour' / 'DefaultDialogues'" regression in ``tests/test_act_storage.py``, ``tests/test_docs/test_build_aea_programmatically/test_programmatic_aea.py``, and ``tests/test_connections/test_scaffold.py`` — the failure mode that ``a0d759e7c`` (drop ``__file__`` equality from cache-hit check) was written to fix. Commit ``96b1785c7`` had re-introduced it by restoring the strict check. Reverts ``96b1785c7``: ``aea.helpers.base.load_module`` reuses any module already registered at the requested canonical dotted path, regardless of which physical file the second call points at. This is the cache-keep-first behaviour the rest of this PR depends on — it lets open-autonomy's ``_MetaPayload.registry`` collapse the short+long duplicate keys into a single class object. ``tests/test_cli/test_run.py::TestRunFailsWhenConnectionClassNotPresent::test_run`` was the one test that depended on the strict-cache behaviour: it stubs ``http_client/connection.py`` with a class-less body and expects the next ``aea run`` to read the stub from disk. Under cache-keep-first the cached real ``http_client`` connection (loaded by an earlier test in the same pytest session) is reused and the stub on disk is ignored, so the documented "class not found" error never fires and the agent boots. Rewrites the test to invoke ``aea run`` via ``PexpectWrapper`` — the pattern already used by the happy-path ``test_run[None]`` above. A subprocess starts with an empty ``sys.modules``, which is exactly how production ``aea run`` runs, so the cache-vs-disk question doesn't arise and the test exercises the same code path an end user hits. Asserts the documented "Connection class 'HTTPClientConnection' not found" message and a non-zero exit code. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 15ecbba commit 277a31c

3 files changed

Lines changed: 68 additions & 48 deletions

File tree

aea/helpers/base.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -127,37 +127,31 @@ 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`` 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
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
139140
# ``ImportError`` to match the standard import semantics rather
140141
# than silently overwriting it with a fresh exec.
141142
if dotted_path in sys.modules:
142143
existing = sys.modules[dotted_path]
143144
if existing is None:
144145
raise ImportError(f"import of {dotted_path!r} halted; None in sys.modules")
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
146+
return existing
153147
spec = importlib.util.spec_from_file_location(dotted_path, str(filepath))
154148
module = importlib.util.module_from_spec(cast(ModuleSpec, spec))
155149
# Register before exec so a downstream `import` of the same dotted
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.
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.
161155
sys.modules[dotted_path] = module
162156
try:
163157
spec.loader.exec_module(module) # type: ignore

tests/test_cli/test_run.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,19 +1336,43 @@ def setup_class(cls):
13361336
).write_text("import packages.valory.protocols.http")
13371337

13381338
def test_run(self):
1339-
"""Run the test."""
1339+
"""Run the test.
1340+
1341+
``aea run`` is invoked as a subprocess via ``PexpectWrapper`` —
1342+
the same pattern as ``test_run[None]`` above. A subprocess
1343+
starts with an empty ``sys.modules`` (the production scenario),
1344+
so ``load_module`` reads the stubbed ``connection.py`` from
1345+
disk and surfaces the documented "Connection class … not
1346+
found" error, exactly as an end user would see it.
1347+
"""
13401348
expected_message = (
1341-
"Package loading error: An error occurred while loading connection {}: Connection class '{"
1342-
"}' not found.".format(self.connection_id, "HTTPClientConnection")
1349+
f"Package loading error: An error occurred while loading "
1350+
f"connection {self.connection_id}: Connection class "
1351+
f"'HTTPClientConnection' not found."
13431352
)
1344-
with pytest.raises(ClickException, match=expected_message):
1345-
self.run_cli_command(
1353+
process = PexpectWrapper( # nosec
1354+
[
1355+
sys.executable,
1356+
"-m",
1357+
"aea.cli",
13461358
"--skip-consistency-check",
13471359
"run",
13481360
"--connections",
13491361
self.connection_id,
1350-
cwd=self._get_cwd(),
1351-
)
1362+
],
1363+
cwd=self._get_cwd(),
1364+
env=os.environ.copy(),
1365+
maxread=10000,
1366+
encoding="utf-8",
1367+
logfile=sys.stdout,
1368+
)
1369+
try:
1370+
process.expect(re.escape(expected_message), timeout=30)
1371+
process.wait_to_complete(10)
1372+
assert process.returncode not in (None, 0)
1373+
finally:
1374+
process.terminate()
1375+
process.wait_to_complete(10)
13521376

13531377

13541378
class TestRunFailsWhenProtocolConfigFileNotFound:

tests/test_skills/test_base.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -511,16 +511,17 @@ 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_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.
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.
524525
"""
525526
import sys
526527

@@ -537,21 +538,22 @@ def test_load_module_reuses_cached_module_for_same_file(tmp_path):
537538
first = load_module(dotted_path, same_src)
538539
first_marker = first.Marker # type: ignore[attr-defined]
539540

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

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.
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.
548550
third = load_module(dotted_path, other_src)
549-
assert third is not first, (
550-
"load_module returned the stale cached module instead of "
551-
"executing the new source"
551+
assert third is first, (
552+
"load_module re-executed when the cached dotted path was "
553+
"called with a different physical file"
552554
)
553-
assert hasattr(third, "Other")
554-
assert not hasattr(third, "Marker")
555+
assert hasattr(third, "Marker")
556+
assert not hasattr(third, "Other")
555557
finally:
556558
sys.modules.pop(dotted_path, None)
557559

0 commit comments

Comments
 (0)