fix(pm): refresh dependencies with the installed project tools
The uv step used a nonexistent command. Both dependency steps used the caller directory instead of the PM repository. Run uv lock --upgrade and the installed npm executable in the correct project. Require the installed tool closure without installing a fallback. Reuse npm environment sanitization for unpack and update. The separately pinned npm keeps its Node dependency on PATH without changing the parent. Missing tools and failed commands return failure before venv sync. Real uv and npm commands ran in guarded temporary projects. The test removes Node's bundled npm before update and preserves unrelated files. The missing-Python test checks the actual refusal and unchanged facts. All 87 focused tests pass. Lint passes. No project pins or locks changed.
This commit is contained in:
21
pm/cli.py
21
pm/cli.py
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from pm.ensure import _facts, _lockfile, _store, ensure, stage_only
|
||||
from pm.ensure import uv as pm_uv
|
||||
from pm.package import InstallError
|
||||
from pm.paths import repo_root
|
||||
from pm.registry import get_package
|
||||
from pm.store import ALL_TARGETS, current_target, hash_url
|
||||
from pm.update import Resolved, resolve_package
|
||||
@@ -325,7 +326,7 @@ def cmd_update(args) -> int:
|
||||
return 1
|
||||
if args.check:
|
||||
if args.uv:
|
||||
print("uv deps: would run `uv update` + venv sync")
|
||||
print("uv deps: would run `uv lock --upgrade` + venv sync")
|
||||
if args.npm:
|
||||
print("npm deps: would run `npm update`")
|
||||
return 1 if changed else 0
|
||||
@@ -352,13 +353,13 @@ def cmd_update(args) -> int:
|
||||
print("pm update: nothing to update")
|
||||
|
||||
if args.uv:
|
||||
uv_bin, env = pm_uv()
|
||||
uv_bin, env = pm_uv(realize=False)
|
||||
if uv_bin is None:
|
||||
print("✗ uv: not installed")
|
||||
return 1
|
||||
code, tail = _run_live([uv_bin, "update"], cwd=".", env=env)
|
||||
code, tail = _run_live([uv_bin, "lock", "--upgrade"], cwd=str(repo_root()), env=env)
|
||||
if code != 0:
|
||||
print(f"✗ uv update failed:\n{tail}")
|
||||
print(f"✗ uv lock --upgrade failed:\n{tail}")
|
||||
return 1
|
||||
print("✓ uv.lock refreshed")
|
||||
try:
|
||||
@@ -369,7 +370,17 @@ def cmd_update(args) -> int:
|
||||
print(f"✗ {e}")
|
||||
return 1
|
||||
if args.npm:
|
||||
code, tail = _run_live(["npm", "update"], cwd=".", env=dict(os.environ))
|
||||
from pm.ensure import env_for, installed_package
|
||||
from pm.packages import npm_env
|
||||
from pm.paths import writable_store_root
|
||||
|
||||
npm = installed_package("npm")
|
||||
node = installed_package("node")
|
||||
if npm is None or npm.binary is None or node is None or node.binary is None:
|
||||
print("✗ npm or Node: not installed; run `hermes pm install`")
|
||||
return 1
|
||||
env = npm_env(writable_store_root() / ".npm-cache", env_for("npm"))
|
||||
code, tail = _run_live([str(npm.binary), "update"], cwd=str(repo_root()), env=env)
|
||||
if code != 0:
|
||||
print(f"✗ npm update failed:\n{tail}")
|
||||
return 1
|
||||
|
||||
@@ -556,6 +556,18 @@ class TermuxDocker(Package):
|
||||
return ""
|
||||
|
||||
|
||||
def npm_env(cache_dir: Path, base_env: Optional[dict] = None) -> dict[str, str]:
|
||||
"""Keep ambient Node and npm options out of PM's child process."""
|
||||
env = {
|
||||
key: value
|
||||
for key, value in (os.environ if base_env is None else base_env).items()
|
||||
if not key.lower().startswith("npm_config_")
|
||||
and key.upper() not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV")
|
||||
}
|
||||
env["npm_config_cache"] = str(cache_dir)
|
||||
return env
|
||||
|
||||
|
||||
@register
|
||||
class Npm(BinaryPackage):
|
||||
name = "npm"
|
||||
@@ -616,13 +628,7 @@ class Npm(BinaryPackage):
|
||||
if not bundled_cli.is_file():
|
||||
raise InstallError(self.name, "node's entry is missing its bundled npm-cli.js")
|
||||
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.lower().startswith("npm_config_")
|
||||
and key not in ("NODE_OPTIONS", "NODE_PATH", "NODE_ENV")
|
||||
}
|
||||
env["npm_config_cache"] = str(archive.parent / ".npm-cache")
|
||||
env = npm_env(archive.parent / ".npm-cache")
|
||||
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.run(
|
||||
|
||||
177
tests/pm/test_update_dependency_legs.py
Normal file
177
tests/pm/test_update_dependency_legs.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Optional dependency refreshes run in PM's repository with its installed tools."""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from pm import cli, paths, registry
|
||||
from pm.lock import Facts, Lockfile
|
||||
from pm.package import Package
|
||||
from pm.packages import Nodejs, Npm, Python, Uv
|
||||
from pm.store import current_target
|
||||
|
||||
|
||||
class ManualFixture(Package):
|
||||
name = 'manual-fixture'
|
||||
|
||||
|
||||
def project(tmp_path, monkeypatch):
|
||||
repo = tmp_path / 'repo'
|
||||
repo.mkdir()
|
||||
(repo / 'pyproject.toml').write_text(
|
||||
'[project]\nname="update-proof"\nversion="1"\nrequires-python=">=3.14"\n'
|
||||
'[tool.uv]\npackage=false\n', encoding='utf-8')
|
||||
caller = tmp_path / 'unrelated-project'
|
||||
caller.mkdir()
|
||||
(caller / 'pyproject.toml').write_text('not a project', encoding='utf-8')
|
||||
lock = Lockfile(repo / 'pm/lock.json')
|
||||
lock.set_pin('manual-fixture', '1', {})
|
||||
lock.save()
|
||||
monkeypatch.setitem(registry._packages, 'manual-fixture', ManualFixture())
|
||||
monkeypatch.setattr(paths, 'repo_root', lambda: repo)
|
||||
monkeypatch.setattr(cli, 'repo_root', lambda: repo)
|
||||
monkeypatch.setattr(paths, 'lockfile_path', lambda: lock.path)
|
||||
monkeypatch.setenv('HERMES_HOME', str(tmp_path / 'home'))
|
||||
monkeypatch.setenv('HERMES_RUNTIME_DIR', str(tmp_path / 'store'))
|
||||
monkeypatch.chdir(caller)
|
||||
|
||||
run_live = cli._run_live
|
||||
|
||||
def run_owned(cmd, *, cwd, env, **kwargs):
|
||||
assert Path(cwd).resolve() == repo.resolve(), 'refuse a dependency command outside the temporary project'
|
||||
assert Path(cmd[0]).resolve().is_relative_to(tmp_path.resolve()), 'refuse a tool outside the temporary store'
|
||||
return run_live(cmd, cwd=cwd, env=env, **kwargs)
|
||||
|
||||
monkeypatch.setattr(cli, '_run_live', run_owned)
|
||||
return repo, caller, lock
|
||||
|
||||
|
||||
@pytest.mark.platforms('windows', 'posix')
|
||||
def test_uv_refresh_uses_real_installed_tool_and_only_the_owned_project(tmp_path, monkeypatch, capsys):
|
||||
repo, caller, lock = project(tmp_path, monkeypatch)
|
||||
uv = Path(shutil.which('uv') or pytest.fail('canonical test environment requires uv'))
|
||||
runtime = tmp_path / 'store'
|
||||
managed = runtime / 'uv-fixture' / uv.name
|
||||
managed.parent.mkdir(parents=True)
|
||||
shutil.copy2(uv, managed)
|
||||
python = Path(sys._base_executable).resolve()
|
||||
python_root = python.parent if os.name == 'nt' else python.parents[1]
|
||||
target = current_target()
|
||||
facts = Facts(runtime / 'facts.json')
|
||||
for name, package, entry in [('uv', Uv(), managed.parent), ('python', Python(), python_root)]:
|
||||
monkeypatch.setitem(registry._packages, name, package)
|
||||
lock.set_pin(name, 'fixture', {target: {'url': f'https://example.invalid/{name}', 'sha256': '1' * 64}})
|
||||
facts.record(name, 'fixture', str(entry), package.env(entry, target), runtime,
|
||||
target=target, artifacts=['1' * 64])
|
||||
lock.save()
|
||||
before_lock = lock.path.read_bytes()
|
||||
parent_env = dict(os.environ)
|
||||
syncs = []
|
||||
ensure = importlib.import_module('pm.ensure')
|
||||
monkeypatch.setattr(ensure, 'sync_venv', lambda **kw: syncs.append(kw))
|
||||
assert cli.main(['update', 'manual-fixture', '--uv', '--check']) == 0
|
||||
assert not (repo / 'uv.lock').exists()
|
||||
assert not syncs
|
||||
check_output = capsys.readouterr().out
|
||||
assert cli.main(['update', 'manual-fixture', '--uv']) == 0
|
||||
assert 'uv lock --upgrade' in check_output
|
||||
assert (repo / 'uv.lock').is_file()
|
||||
assert not (caller / 'uv.lock').exists()
|
||||
assert syncs == [{'explicit': True}]
|
||||
assert lock.path.read_bytes() == before_lock
|
||||
assert dict(os.environ) == parent_env
|
||||
|
||||
original = (repo / 'uv.lock').read_bytes()
|
||||
syncs.clear()
|
||||
(repo / 'pyproject.toml').write_text('invalid project [', encoding='utf-8')
|
||||
assert cli.main(['update', 'manual-fixture', '--uv']) == 1
|
||||
assert (repo / 'uv.lock').read_bytes() == original and not syncs
|
||||
assert 'uv lock --upgrade failed' in capsys.readouterr().out
|
||||
|
||||
managed.unlink()
|
||||
assert cli.main(['update', 'manual-fixture', '--uv']) == 1
|
||||
assert (repo / 'uv.lock').read_bytes() == original and not syncs
|
||||
assert 'not installed' in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.platforms('windows', 'posix')
|
||||
def test_npm_refresh_uses_its_installed_entry_and_owned_project(monkeypatch, capsys):
|
||||
node_source = Path(shutil.which('node') or pytest.fail('node is required'))
|
||||
npm_source = Path(shutil.which('npm.cmd' if os.name == 'nt' else 'npm') or pytest.fail('npm is required')).resolve()
|
||||
npm_source = npm_source.parent / 'node_modules/npm' if os.name == 'nt' else npm_source.parents[1]
|
||||
assert (npm_source / 'bin/npm-cli.js').is_file()
|
||||
# Keep the real tool's argv clear of the harness's hermes-update guard.
|
||||
temp_root = Path(os.environ['LOCALAPPDATA']) / 'Temp' if os.name == 'nt' else Path('/tmp')
|
||||
with tempfile.TemporaryDirectory(prefix='pm-deps-', dir=temp_root) as temporary, monkeypatch.context() as scoped:
|
||||
monkeypatch = scoped
|
||||
root = Path(temporary)
|
||||
repo, caller, lock = project(root, monkeypatch)
|
||||
user = root / 'user'
|
||||
user.mkdir()
|
||||
for key in ('HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA'):
|
||||
monkeypatch.setenv(key, str(user))
|
||||
target = current_target()
|
||||
runtime = root / 'store'
|
||||
node_entry = runtime / 'node-fixture'
|
||||
node_binary = node_entry / ('node.exe' if os.name == 'nt' else 'bin/node')
|
||||
node_binary.parent.mkdir(parents=True)
|
||||
shutil.copy2(node_source, node_binary)
|
||||
bundled_npm = node_entry / ('node_modules/npm' if os.name == 'nt' else 'lib/node_modules/npm')
|
||||
shutil.copytree(npm_source, bundled_npm)
|
||||
facts = Facts(runtime / 'facts.json')
|
||||
node, npm = Nodejs(), Npm()
|
||||
monkeypatch.setitem(registry._packages, 'node', node)
|
||||
monkeypatch.setitem(registry._packages, 'npm', npm)
|
||||
lock.set_pin('node', 'fixture', {target: {'url': 'https://example.invalid/node', 'sha256': '1' * 64}})
|
||||
facts.record('node', 'fixture', str(node_entry), node.env(node_entry, target), runtime,
|
||||
target=target, artifacts=['1' * 64])
|
||||
lock.save()
|
||||
archive = root / 'npm.tgz'
|
||||
with tarfile.open(archive, 'w:gz') as output:
|
||||
output.add(npm_source, arcname='package')
|
||||
npm_entry = runtime / 'npm-fixture'
|
||||
npm.unpack(archive, npm_entry, target)
|
||||
version = json.loads((npm_source / 'package.json').read_text(encoding='utf-8'))['version']
|
||||
lock.set_pin('npm', version, {target: {'url': 'https://example.invalid/npm', 'sha256': '2' * 64}})
|
||||
facts.record('npm', version, str(npm_entry), npm.env(npm_entry, target), runtime,
|
||||
target=target, artifacts=['2' * 64])
|
||||
lock.save()
|
||||
# After install, Node's bundled npm is no longer available as a fallback.
|
||||
shutil.rmtree(bundled_npm)
|
||||
(repo / 'package.json').write_text(json.dumps({'name': 'pm-leg-proof', 'version': '1.0.0', 'private': True}), encoding='utf-8')
|
||||
(repo / '.npmrc').write_text('offline=true\naudit=false\nfund=false\nignore-scripts=true\n', encoding='utf-8')
|
||||
monkeypatch.setenv('PATH', str(Path(os.environ.get('SYSTEMROOT', '/')) / 'System32') if os.name == 'nt' else '/usr/bin:/bin')
|
||||
monkeypatch.setenv('NODE_OPTIONS', '--invalid-option-to-be-scrubbed')
|
||||
monkeypatch.setenv('npm_config_cache', str(root / 'ambient-cache'))
|
||||
before = dict(os.environ)
|
||||
before_lock = lock.path.read_bytes()
|
||||
assert cli.main(['update', 'manual-fixture', '--npm', '--check']) == 0
|
||||
assert not (repo / 'package-lock.json').exists()
|
||||
assert cli.main(['update', 'manual-fixture', '--npm']) == 0, capsys.readouterr().out
|
||||
assert json.loads((repo / 'package-lock.json').read_text(encoding='utf-8'))['name'] == 'pm-leg-proof'
|
||||
assert not (caller / 'package-lock.json').exists()
|
||||
assert dict(os.environ) == before
|
||||
assert lock.path.read_bytes() == before_lock
|
||||
assert not (root / 'ambient-cache').exists()
|
||||
lock_bytes = (repo / 'package-lock.json').read_bytes()
|
||||
(repo / 'package.json').write_text('not json', encoding='utf-8')
|
||||
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
|
||||
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
|
||||
assert 'npm update failed' in capsys.readouterr().out
|
||||
|
||||
node_binary.rename(node_binary.with_suffix('.held'))
|
||||
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
|
||||
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
|
||||
assert 'not installed' in capsys.readouterr().out
|
||||
node_binary.with_suffix('.held').rename(node_binary)
|
||||
npm.binary(npm_entry, target).unlink()
|
||||
assert cli.main(['update', 'manual-fixture', '--npm']) == 1
|
||||
assert (repo / 'package-lock.json').read_bytes() == lock_bytes
|
||||
assert 'not installed' in capsys.readouterr().out
|
||||
assert dict(os.environ) == before and lock.path.read_bytes() == before_lock
|
||||
@@ -120,6 +120,9 @@ def test_uv_refuses_discovery_when_pm_python_is_missing(installed_uv, monkeypatc
|
||||
entry.mkdir()
|
||||
facts.record("python", "test", entry.name, {}, entry.parent,
|
||||
target=target, artifacts=[digest])
|
||||
recorded = facts.path.read_bytes()
|
||||
assert ensure.uv(realize=False)[0] is None
|
||||
with pytest.raises(InstallError, match="binary is missing"):
|
||||
with pytest.raises(InstallError, match="lazy installs are disabled: python"):
|
||||
ensure.uv()
|
||||
assert facts.path.read_bytes() == recorded
|
||||
assert not list(entry.iterdir())
|
||||
|
||||
Reference in New Issue
Block a user