mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 02:01:47 +00:00
test(gateway): isolate plugin adapter imports and guard the anti-pattern
Fixes the xdist collision that broke CI on PR #17764, and structurally prevents future plugin-adapter tests from reintroducing it. Problem ------- tests/gateway/test_teams.py (new in this PR) and tests/gateway/test_irc_adapter.py (already on main) both followed the same anti-pattern: sys.path.insert(0, str(_REPO_ROOT / 'plugins' / 'platforms' / '<name>')) from adapter import <Adapter> Every platform plugin ships its own adapter.py, so the bare 'from adapter import ...' races for sys.modules['adapter']. Whichever test collected first in a given xdist worker won; the other crashed at collection with ImportError, and the polluted sys.path cascaded into 19 unrelated test failures across tools/, hermes_cli/, and run_agent/ in the same worker. Fix --- 1. tests/gateway/_plugin_adapter_loader.py (new): shared helper load_plugin_adapter('<name>') that imports plugins/platforms/<name>/adapter.py via importlib.util under the unique module name plugin_adapter_<name>. Zero sys.path mutation, no possibility of collision. 2. tests/gateway/test_irc_adapter.py and tests/gateway/test_teams.py: migrated to the helper. All 'from adapter import ...' statements (including the ones inside test methods) are replaced with module-level attribute access on the loaded module. 3. tests/gateway/conftest.py: new pytest_configure guard that AST-scans every test_*.py under tests/gateway/ at session start and fails the run with a pointer to the helper if any test uses sys.path.insert into plugins/platforms/ OR a bare 'import adapter' / 'from adapter import'. Runs on the xdist controller only (skipped in workers). The next plugin adapter test that tries to reintroduce this pattern gets rejected at collection time with a clear remediation message. 4. scripts/release.py: add aamirjawaid@microsoft.com -> heyitsaamir to AUTHOR_MAP so the check-attribution workflow passes. Validation ---------- scripts/run_tests.sh tests/gateway/ 4194 passed scripts/run_tests.sh tests/gateway/test_{teams,irc}* 72 passed (both orderings) scripts/run_tests.sh <11 prev-failing test files> 398 passed Guard triggers correctly on both Path-operator and string-literal forms of the anti-pattern.
This commit is contained in:
parent
e23bb18dac
commit
26787ce638
5 changed files with 239 additions and 27 deletions
72
tests/gateway/_plugin_adapter_loader.py
Normal file
72
tests/gateway/_plugin_adapter_loader.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Shared helper for loading platform-plugin ``adapter.py`` modules in tests.
|
||||
|
||||
Every platform plugin under ``plugins/platforms/<name>/`` ships its own
|
||||
``adapter.py``. If two tests independently do::
|
||||
|
||||
sys.path.insert(0, "plugins/platforms/irc")
|
||||
from adapter import IRCAdapter
|
||||
|
||||
sys.path.insert(0, "plugins/platforms/teams")
|
||||
from adapter import TeamsAdapter
|
||||
|
||||
…then whichever collects first in an xdist worker wins
|
||||
``sys.modules["adapter"]``, and the other raises ``ImportError`` at
|
||||
collection time. The fallout cascades across unrelated tests sharing that
|
||||
worker because ``sys.path`` is still polluted.
|
||||
|
||||
Use :func:`load_plugin_adapter` instead of ad-hoc ``sys.path`` tricks.
|
||||
It loads the adapter from an explicit file path under a unique module
|
||||
name (``plugin_adapter_<plugin_name>``), so it cannot collide with any
|
||||
other plugin's adapter module.
|
||||
|
||||
The ``tests/gateway/conftest.py`` guard rejects the anti-pattern at
|
||||
collection time so this can't regress when new plugin adapter tests are
|
||||
added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_PLUGINS_DIR = _REPO_ROOT / "plugins" / "platforms"
|
||||
|
||||
|
||||
def load_plugin_adapter(plugin_name: str) -> ModuleType:
|
||||
"""Import ``plugins/platforms/<plugin_name>/adapter.py`` in isolation.
|
||||
|
||||
The module is registered under the unique name
|
||||
``plugin_adapter_<plugin_name>`` in ``sys.modules``. No ``sys.path``
|
||||
mutation. Safe to call multiple times — repeat calls return the
|
||||
already-loaded module.
|
||||
"""
|
||||
module_name = f"plugin_adapter_{plugin_name}"
|
||||
cached = sys.modules.get(module_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
adapter_path = _PLUGINS_DIR / plugin_name / "adapter.py"
|
||||
if not adapter_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Plugin adapter not found: {adapter_path}. "
|
||||
f"Known plugins: {sorted(p.name for p in _PLUGINS_DIR.iterdir() if p.is_dir())}"
|
||||
)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, adapter_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not build import spec for {adapter_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register BEFORE exec so the module can find itself if needed (some
|
||||
# modules do ``sys.modules[__name__]`` reflection during import).
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
return module
|
||||
Loading…
Add table
Add a link
Reference in a new issue