The rebased file trips the pre-push code-health F811 rule: pytest injects the fixture imported from tests.pm by parameter name, which ruff reads as a redefinition. Reviewed allowance on the line, same remedy the rule documents.
164 lines
9.1 KiB
Python
164 lines
9.1 KiB
Python
"""Only declared Python dependencies require Python install consent."""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from hermes_cli import plugins_cmd
|
|
from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401
|
|
|
|
|
|
@pytest.mark.parametrize('python_surface', [None, 'pyproject', 'legacy'])
|
|
def test_install_only_requests_relevant_python_consent(admission_env, monkeypatch, capsys, python_surface):
|
|
root, home = admission_env
|
|
source = root / 'plugin-source'
|
|
source.mkdir()
|
|
manifest = {'name': 'sidecar-plugin'}
|
|
if python_surface == 'legacy':
|
|
manifest['python_dependencies'] = ['never-install-without-consent==1']
|
|
(source / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8')
|
|
(source / '__init__.py').write_text('def register(ctx):\n pass\n', encoding='utf-8')
|
|
(source / 'package.json').write_text(json.dumps({'name': 'sidecar-plugin', 'version': '1.0.0'}), encoding='utf-8')
|
|
if python_surface == 'pyproject':
|
|
(source / 'pyproject.toml').write_text(
|
|
'[project]\nname="sidecar-plugin"\nversion="1.0"\nrequires-python=">=3.14"\n', encoding='utf-8')
|
|
env = {k: v for k, v in os.environ.items() if k not in {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'}}
|
|
for args in [('init', '-q'), ('add', '.'), ('-c', 'user.name=test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')]:
|
|
subprocess.run(['git', *args], cwd=source, env=env, check=True, capture_output=True, timeout=30)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
|
|
config_path = home / 'config.yaml'
|
|
before = config_path.read_bytes()
|
|
plugins_cmd.cmd_install(source.as_uri(), enable=True, allow_removed=True)
|
|
installed = home / 'plugins' / 'sidecar-plugin'
|
|
assert (installed / 'package.json').is_file()
|
|
assert not (installed / 'node_modules').exists()
|
|
enabled = yaml.safe_load(config_path.read_text(encoding='utf-8'))['plugins']['enabled']
|
|
output = capsys.readouterr().out
|
|
if python_surface is None:
|
|
assert 'sidecar-plugin' in enabled
|
|
assert 'declares Python dependencies' not in output
|
|
else:
|
|
assert 'sidecar-plugin' not in enabled
|
|
assert config_path.read_bytes() == before
|
|
assert 'dependency install skipped (non-interactive)' in output
|
|
|
|
|
|
def test_node_sidecar_question_stays_independent(tmp_path, monkeypatch):
|
|
from pm import workspace
|
|
|
|
target = tmp_path / 'plugin'
|
|
target.mkdir()
|
|
(target / 'package.json').write_text('{}', encoding='utf-8')
|
|
prompts, installs, lines = [], [], []
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, 'isatty', lambda: True)
|
|
monkeypatch.setattr('builtins.input', lambda prompt: prompts.append(prompt) or 'yes')
|
|
monkeypatch.setattr(workspace, 'install_node_sidecar', lambda path, **kwargs: installs.append((path, kwargs)))
|
|
from types import SimpleNamespace
|
|
|
|
result = plugins_cmd._install_plugin_python_deps(
|
|
{'name': 'sidecar'}, target, SimpleNamespace(print=lambda *args, **kwargs: lines.extend(args)))
|
|
assert result == (True, None)
|
|
assert installs == [(target, {'explicit': True})]
|
|
assert len(prompts) == 1 and 'node_modules' in prompts[0]
|
|
assert not any('Python dependencies' in str(line) for line in lines)
|
|
|
|
|
|
def test_yes_deps_carries_consent_through_non_interactive_install(admission_env, monkeypatch, capsys):
|
|
"""--yes-deps (#122134): the flag is the user's own answer to the consent
|
|
question, so a non-interactive install publishes AND enables in one run
|
|
instead of being refused (the `enable` recovery cannot publish a plugin)."""
|
|
root, home = admission_env
|
|
source = root / 'plugin-source'
|
|
source.mkdir()
|
|
(source / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'yes-deps-plugin'}), encoding='utf-8')
|
|
(source / '__init__.py').write_text('def register(ctx):\n pass\n', encoding='utf-8')
|
|
(source / 'pyproject.toml').write_text(
|
|
'[project]\nname="yes-deps-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n', encoding='utf-8')
|
|
env = {k: v for k, v in os.environ.items() if k not in {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'}}
|
|
for args in [('init', '-q'), ('add', '.'), ('-c', 'user.name=test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')]:
|
|
subprocess.run(['git', *args], cwd=source, env=env, check=True, capture_output=True, timeout=30)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
|
|
|
|
plugins_cmd.cmd_install(source.as_uri(), enable=True, allow_removed=True, yes_deps=True)
|
|
|
|
enabled = yaml.safe_load((home / 'config.yaml').read_text(encoding='utf-8'))['plugins']['enabled']
|
|
output = capsys.readouterr().out
|
|
# The install publishes into plugins/ and THEN a second PM admission transaction
|
|
# enables it; that admission catches every exception as a refusal, so an
|
|
# environment-sensitive failure there leaves the plugin installed-but-disabled
|
|
# with exit 0. Carry the CLI output in the failure message so the refusal's
|
|
# cause is visible on any machine instead of a bare `in []`.
|
|
assert 'yes-deps-plugin' in enabled, (
|
|
'install completed but the plugin was not enabled; CLI output:\n' + output)
|
|
assert 'dependency install skipped (non-interactive)' not in output
|
|
|
|
|
|
def test_dashboard_install_publishes_a_plugin_the_config_already_selects(admission_env, monkeypatch): # health: allow F811 -- pytest injects the imported admission_env fixture by parameter name
|
|
"""The Desktop/dashboard Install click on a plugin memory.provider already names (a provider that
|
|
left core, a synced config, a re-install after remove) publishes like any fresh install instead of
|
|
the non-interactive 'Install declined' that a retry can never get past. A forced replacement of an
|
|
installed plugin keeps the veto: that is the reinstall the click did not review."""
|
|
root, home = admission_env
|
|
source = root / 'mem-source'
|
|
source.mkdir()
|
|
(source / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'mem-twin'}), encoding='utf-8')
|
|
(source / '__init__.py').write_text('class Twin(MemoryProvider): ...\n', encoding='utf-8')
|
|
(source / 'pyproject.toml').write_text(
|
|
'[project]\nname="mem-twin"\nversion="1.0"\nrequires-python=">=3.11"\n', encoding='utf-8')
|
|
env = {k: v for k, v in os.environ.items() if k not in {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'}}
|
|
for args in [('init', '-q'), ('add', '.'), ('-c', 'user.name=test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')]:
|
|
subprocess.run(['git', *args], cwd=source, env=env, check=True, capture_output=True, timeout=30)
|
|
config = yaml.safe_load((home / 'config.yaml').read_text(encoding='utf-8'))
|
|
config['memory'] = {'provider': 'mem-twin'}
|
|
(home / 'config.yaml').write_text(yaml.safe_dump(config), encoding='utf-8')
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
|
|
|
|
result = plugins_cmd.dashboard_install_plugin(source.as_uri(), force=False, enable=True)
|
|
assert result['ok'], result
|
|
assert (home / 'plugins' / 'mem-twin' / 'plugin.yaml').is_file()
|
|
again = plugins_cmd.dashboard_install_plugin(source.as_uri(), force=True, enable=True)
|
|
assert not again['ok'] and 'Reinstall declined' in again['error']
|
|
|
|
|
|
def test_publish_assume_consent_skips_replacement_veto(tmp_path, monkeypatch):
|
|
"""The active-replacement veto at publication honors --yes-deps: with the
|
|
explicit answer the PM handoff runs; the default without it stays the
|
|
non-interactive refusal (#122134's 'Reinstall declined' half)."""
|
|
from hermes_cli import plugins_transaction
|
|
from pm import workspace as ws
|
|
|
|
staged, target = tmp_path / 'staged', tmp_path / 'target'
|
|
staged.mkdir()
|
|
target.mkdir()
|
|
(staged / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'active-plugin'}), encoding='utf-8')
|
|
(staged / 'pyproject.toml').write_text(
|
|
'[project]\nname="active-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n', encoding='utf-8')
|
|
monkeypatch.setattr(ws, 'enabled_plugin_dirs', lambda **kwargs: [target])
|
|
syncs = []
|
|
monkeypatch.setattr('pm.client.sync_venv', lambda **kwargs: syncs.append(kwargs))
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
|
|
|
|
plugins_transaction.publish_plugin(
|
|
staged, target, {}, {'active-plugin': {}}, require_consent=True, assume_consent=True)
|
|
assert len(syncs) == 1
|
|
|
|
with pytest.raises(plugins_cmd.PluginOperationError, match='Reinstall declined'):
|
|
plugins_transaction.publish_plugin(staged, target, {}, {'active-plugin': {}}, require_consent=True)
|
|
|
|
|
|
def test_install_parser_offers_yes_deps_exclusive_with_no_deps():
|
|
import argparse
|
|
|
|
from hermes_cli.subcommands.plugins import build_plugins_parser
|
|
|
|
parser = argparse.ArgumentParser(prog='hermes')
|
|
build_plugins_parser(parser.add_subparsers(), cmd_plugins=lambda args: None)
|
|
ns = parser.parse_args(['plugins', 'install', 'x', '--yes-deps'])
|
|
assert ns.yes_deps is True
|
|
assert ns.no_deps is False
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(['plugins', 'install', 'x', '--no-deps', '--yes-deps'])
|