A PM environment build keeps the recorded extras selection and never reads config, so a home with platforms.discord (or telegram) enabled came out of `hermes update` without the SDK: the update child detected it (_configured_features_missing_deps) and only printed a warning, and the gateway's lazy install at connect time is the only recovery — which fails silently when lazy installs are off or the process is not on the selected generation. The update-build child (fresh, selected interpreter) now maps each missing configured feature to the pyproject extra named after it (platform name -> extra, MCP -> mcp), keeps only declared, platform-supported extras, and adds them with an explicit pm.sync_venv. Detection reuses the gateway's own connected-platform semantics (config + env credentials), so it installs exactly what the gateway will try to load on restart. Features it could not install still get the warning; an install failure never fails the update. Flips tests/e2e/core/upgrade/pm/test_configured_features.py:: test_configured_gateway_platform_has_its_sdk_after_update.
137 lines
6.7 KiB
Python
137 lines
6.7 KiB
Python
"""Configured-feature warnings must describe the selected update child (#10651).
|
|
|
|
PM's atomic dependency union cannot validate a configured but unselected SDK.
|
|
These tests use real PM publication and a fresh interpreter; only expensive
|
|
frontend compilation is replaced. SDK anchors exist only in the target tree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import sysconfig
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
import pm
|
|
from pm.environments import activation_environment, runtime_facts_path, selected_venv, site_packages
|
|
from pm.environments import venv_python
|
|
from tests.pm._fixtures import isolated_python # noqa: F401
|
|
from tests.pm.test_source_update_launch import source_launch # noqa: F401
|
|
|
|
|
|
@pytest.fixture
|
|
def configured_update(source_launch, tmp_path, monkeypatch):
|
|
root, _, _ = source_launch
|
|
repository = Path(__file__).resolve().parents[2]
|
|
pm.sync_venv(["launch-extra"], explicit=True, project_root=root)
|
|
selected = selected_venv(root)
|
|
site = site_packages(selected)
|
|
# Reuse installed core libraries without exposing the updater's site tree:
|
|
# neither SDK, executable .pth hooks, nor editable package finders cross over.
|
|
for path in Path(sysconfig.get_path("purelib")).iterdir():
|
|
if (path.name in {"lark_oapi", "mcp", "__pycache__"}
|
|
or path.name.startswith(("lark_oapi-", "mcp-", "__editable__"))
|
|
or path.suffix == ".pth" or (site / path.name).exists()):
|
|
continue
|
|
(site / path.name).symlink_to(path, target_is_directory=path.is_dir())
|
|
# Application source is unchanged; the toy manifest keeps PM resolution
|
|
# offline while exercising the real selected-environment launch boundary.
|
|
for path in repository.iterdir():
|
|
if path.name in {"hermes_cli", "gateway", "agent", "tools", "plugins", "pm"} or path.suffix == ".py":
|
|
if (root / path.name).exists():
|
|
# source_launch already stubs hermes_cli/source_completion.py; link the rest of that
|
|
# package's modules beside it.
|
|
for module in path.iterdir():
|
|
if not (root / path.name / module.name).exists():
|
|
(root / path.name / module.name).symlink_to(module, target_is_directory=module.is_dir())
|
|
continue
|
|
(root / path.name).symlink_to(path, target_is_directory=path.is_dir())
|
|
def build_in_child():
|
|
script = (
|
|
"import json, sys; from pathlib import Path\n"
|
|
"from hermes_cli import source_build\n"
|
|
"source_build.source_build_env = lambda **kwargs: {}\n"
|
|
"source_build.prepare_source_dependencies = lambda *args, **kwargs: None\n"
|
|
"source_build.build_source_tui = lambda *args, **kwargs: None\n"
|
|
"source_build.build_source_web = lambda *args, **kwargs: None\n"
|
|
f"source_build.build_update_products(Path({str(root)!r}), desktop=False)\n"
|
|
"print('TARGET=' + json.dumps({'prefix': sys.prefix, 'python': sys.executable}))\n"
|
|
)
|
|
subprocess.run([str(venv_python(selected)), "-c", script],
|
|
cwd=root, env=activation_environment(root), check=True)
|
|
home = tmp_path / "home"
|
|
config = home / "config.yaml"
|
|
config.write_text(
|
|
"platforms:\n feishu:\n enabled: true\n extra:\n app_id: cli_x\n app_secret: y\n"
|
|
"mcp_servers:\n fixture:\n command: must-not-run\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
|
# A stale positive verdict in the updater must not conceal missing child SDKs.
|
|
from types import ModuleType
|
|
for name in ("lark_oapi", "mcp"):
|
|
monkeypatch.setitem(sys.modules, name, ModuleType(name))
|
|
return root, selected, site, config, build_in_child
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_update_names_missing_configured_features_from_selected_child(configured_update, capfd):
|
|
root, selected, site, _, build = configured_update
|
|
facts = runtime_facts_path(root).read_bytes()
|
|
build()
|
|
out = capfd.readouterr().out
|
|
assert "fail to load them on restart" in out
|
|
assert "Feishu / Lark" in out and "MCP servers" in out
|
|
assert "hermes setup" in out and "hermes pm" in out
|
|
target = json.loads(next(line.removeprefix("TARGET=") for line in out.splitlines() if line.startswith("TARGET=")))
|
|
assert Path(target["prefix"]) == selected
|
|
assert Path(target["python"]).parent.parent == selected
|
|
assert runtime_facts_path(root).read_bytes() == facts
|
|
assert not (root / ".update-incomplete").exists()
|
|
|
|
# Only the target gains the anchors; no updater module/cache is repaired.
|
|
for name in ("lark_oapi", "mcp"):
|
|
(site / f"{name}.py").write_text("# Passive dependency-probe fixture.\n", encoding="utf-8")
|
|
build()
|
|
assert "fail to load them on restart" not in capfd.readouterr().out
|
|
assert runtime_facts_path(root).read_bytes() == facts
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_unconfigured_or_disabled_features_are_quiet(configured_update, capfd):
|
|
root, _, _, config, build = configured_update
|
|
for body in (
|
|
"platforms:\n feishu:\n enabled: false\n extra:\n app_id: cli_x\n app_secret: y\n",
|
|
"platforms:\n feishu:\n enabled: true\n",
|
|
):
|
|
config.write_text(body, encoding="utf-8")
|
|
build()
|
|
out = capfd.readouterr().out
|
|
assert "TARGET=" in out
|
|
assert "fail to load them on restart" not in out
|
|
assert "MCP servers" not in out
|
|
assert "Feishu / Lark:" not in out
|
|
assert not (root / ".update-incomplete").exists()
|
|
|
|
|
|
def test_update_installs_the_extras_configured_features_need(monkeypatch, tmp_path, capsys):
|
|
"""#124228: an enabled platform's SDK joins the environment instead of only a warning."""
|
|
from hermes_cli import main_install_repair as repair
|
|
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
'[project]\nname = "x"\nversion = "0"\n'
|
|
"[project.optional-dependencies]\ndiscord = []\ngoogle-chat = []\nmcp = []\n", encoding="utf-8")
|
|
monkeypatch.setattr(repair, "_configured_features_missing_deps", lambda: [
|
|
("Discord", "discord hint", "discord"), ("Google Chat", "chat hint", "google_chat"),
|
|
("IRC", "irc hint", "irc"), ("MCP servers", "mcp hint", "mcp")])
|
|
calls = []
|
|
monkeypatch.setattr(pm, "sync_venv", lambda extras, **kwargs: calls.append((extras, kwargs)))
|
|
repair._install_configured_features_missing_deps(tmp_path)
|
|
assert calls == [(["discord", "google-chat", "mcp"],
|
|
{"explicit": True, "project_root": tmp_path, "evict_incompatible_plugins": True})]
|
|
out = capsys.readouterr().out
|
|
assert "IRC: irc hint" in out
|
|
assert "Discord" not in out and "MCP servers" not in out
|