hermes_cli.runtime_paths (venv generations, selection, activation) moves to pm.environments, and gains venv_bin_dir / venv_python / project_python. Every in-tree caller asks pm for an interpreter now; pm no longer reaches back into hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure, pm.paths imported hermes_cli.runtime_paths). The three open-coded "Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python. hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json). To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562) and pm.registry loads the built-in package definitions on first read instead of at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms, was 37ms with the eager facade dragging in the downloader). The stripped-payload fixtures that ship only pre-import files keep working for the same reason. Also restores two frozen-surface re-exports the F401 sweep dropped (banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
139 lines
5.6 KiB
Python
139 lines
5.6 KiB
Python
"""Read every profile's enabled plugins in config order for the shared union.
|
|
|
|
Plugin admission owns writes; discovery never edits a profile's selection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
|
|
def _profiles_root() -> Path:
|
|
# Plugin discovery and dependency publication must use the same home root.
|
|
from pm.environments import dependency_home_root
|
|
|
|
return dependency_home_root() / "profiles"
|
|
|
|
|
|
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
|
|
"""Read a selection once; only an absent file means an unknown home.
|
|
|
|
An unreadable selection must not shrink the next dependency generation.
|
|
Empty YAML is an explicit empty configuration, as in the CLI loader.
|
|
"""
|
|
config_path = home / "config.yaml"
|
|
try:
|
|
text = config_path.read_text(encoding="utf-8-sig")
|
|
except FileNotFoundError:
|
|
return None
|
|
except (OSError, UnicodeError) as exc:
|
|
raise ValueError(f"could not read plugin selection: {config_path}") from exc
|
|
|
|
# Missing YAML support is a broken runtime, not an empty plugin selection.
|
|
import utils
|
|
from ruamel.yaml.error import YAMLError
|
|
|
|
try:
|
|
config = utils.fast_safe_load(text)
|
|
except YAMLError as exc:
|
|
raise ValueError(f"could not parse plugin selection: {config_path}") from exc
|
|
if config is None:
|
|
return {}
|
|
if not isinstance(config, dict):
|
|
raise ValueError(f"configuration must be a mapping: {config_path}")
|
|
for section in ("plugins", "memory"):
|
|
if config.get(section) is not None and not isinstance(config[section], dict):
|
|
raise ValueError(f"{section} must be a mapping: {config_path}")
|
|
plugins = config.get("plugins") or {}
|
|
for key in ("enabled", "disabled"):
|
|
names = plugins.get(key)
|
|
if names is not None and (not isinstance(names, list)
|
|
or any(not isinstance(name, str) for name in names)):
|
|
raise ValueError(f"plugins.{key} must be a list of names: {config_path}")
|
|
provider = (config.get("memory") or {}).get("provider")
|
|
if provider is not None and not isinstance(provider, str):
|
|
raise ValueError(f"memory.provider must be a name: {config_path}")
|
|
return config
|
|
|
|
|
|
def _enabled_from_config(config: dict[str, Any]) -> list[str]:
|
|
"""plugins.enabled from an already-parsed config, ORDER-PRESERVING."""
|
|
plugins_cfg = config.get("plugins")
|
|
if not isinstance(plugins_cfg, dict):
|
|
return []
|
|
enabled = plugins_cfg.get("enabled")
|
|
if not isinstance(enabled, list):
|
|
return []
|
|
disabled = plugins_cfg.get("disabled", [])
|
|
disabled = set(disabled) if isinstance(disabled, list) else set()
|
|
out: list[str] = []
|
|
for name in enabled:
|
|
if (isinstance(name, str) and name and name not in out
|
|
and name not in disabled and name.rsplit("/", 1)[-1] not in disabled):
|
|
out.append(name)
|
|
return out
|
|
|
|
|
|
def _is_directory(path: Path) -> bool:
|
|
import stat
|
|
|
|
try:
|
|
return stat.S_ISDIR(path.stat().st_mode)
|
|
except FileNotFoundError:
|
|
return False
|
|
except OSError as exc:
|
|
raise ValueError(f"could not inspect plugin directory: {path}") from exc
|
|
|
|
|
|
def _all_homes() -> list[Path]:
|
|
"""Enumerate the complete union or refuse; a partial scan cannot remove members."""
|
|
from pm.environments import dependency_home_root
|
|
|
|
homes = [dependency_home_root()]
|
|
root = _profiles_root()
|
|
try:
|
|
profiles = sorted(root.iterdir(), key=str)
|
|
except FileNotFoundError:
|
|
return homes
|
|
except OSError as exc:
|
|
raise ValueError(f"could not enumerate profiles: {root}") from exc
|
|
homes.extend(profile for profile in profiles if _is_directory(profile))
|
|
return homes
|
|
|
|
|
|
def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None, installing: Path | None = None) -> dict[Path, list[str]]:
|
|
"""plugins_dir → ordered enabled list, per home. Keyed by the
|
|
PLUGINS DIR (where the member dirs live), not the home itself.
|
|
|
|
The ACTIVE MEMORY PROVIDER joins its home's list: providers install
|
|
via ``memory.provider`` (mnemosyne's documented path), not via
|
|
plugins.enabled — without this, a provider's dep plugin never joins
|
|
the union. Admission refuses a conflicting candidate without changing
|
|
the active environment or disabling an existing provider."""
|
|
out: dict[Path, list[str]] = {}
|
|
for home in _all_homes():
|
|
# ONE parse per home feeds both queries (enabled + provider).
|
|
config = _read_home_config(home)
|
|
config = config or {}
|
|
if proposed_home is not None and home.resolve() == Path(proposed_home).resolve():
|
|
config = {**config, "plugins": {"enabled": list(enabled or ()), "disabled": list(disabled or ())}}
|
|
names = _enabled_from_config(config)
|
|
provider = _provider_from_config(home, config, installing=installing)
|
|
if provider and provider not in names:
|
|
names.append(provider)
|
|
if names:
|
|
out[home / "plugins"] = names
|
|
return out
|
|
|
|
|
|
def _provider_from_config(home: Path, config: dict[str, Any], *, installing: Path | None = None) -> Optional[str]:
|
|
"""The ``memory.provider`` key of an already-parsed config, when its
|
|
plugin dir exists (no dir = not a member)."""
|
|
provider = (config.get("memory") or {}).get("provider")
|
|
if not provider or not provider.strip():
|
|
return None
|
|
name = provider.strip()
|
|
return name if (_is_directory(home / "plugins" / name) or
|
|
(installing is not None and (home / "plugins" / name).resolve() == installing.resolve())) else None
|