Files
hermes-agent/tests/gateway/_plugin_adapter_loader.py
ethernet 49945b1402 refactor(tests): one runner everywhere — xdist removed, per-file isolation on every host
pytest-xdist is gone: run_tests.sh no longer dispatches on host, the
Windows xdist (--dist loadfile) arm is deleted, and pytest-xdist is
dropped from the dev extra (uv lock removes it + execnet). The
per-file subprocess runner (run_tests_parallel.py) is THE runner on
every host — the shape the CI linux lane already used.

- tests-os.yml: the macOS/Windows lanes now run scripts/run_tests.sh
  like every other lane, passing the marker-narrowed file list via
  --files and -m after --. The runner natively tolerates per-file
  empty collections (exit 5, platform-gated file) and fails the run
  when NOTHING collected — replacing the hand-rolled exit-5 branch.
- tests/gateway/conftest.py: the hasattr(config, 'workerinput')
  controller-guard was xdist-only dead code; the file-locked cache
  already handled the per-file model. Removed.
- tests/tools/test_browser_supervisor.py: the port came from xdist's
  worker_id (fixed 9225 under per-file isolation — concurrent files
  collide). Binds an ephemeral port instead (bind 0, read back).
- ~35 comment sites named xdist as the isolation mechanism; they now
  describe the shared-process hazard they actually guard against
  (bare pytest runs, same-file ordering) without naming a runner that
  no longer exists.
2026-09-04 19:01:41 -04:00

73 lines
2.6 KiB
Python

"""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 a shared process 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