`import pm.ensure` bound the submodule onto the package, shadowing the facade's `pm.ensure()` function for every later caller in the process (photon's sidecar start hit `'module' object is not callable`). The module is pm.install now; the function keeps its name. The facade resolves through `__import__` rather than `importlib.import_module` so a test that patches import_module globally does not break attribute access on pm. tools/lazy_deps.py returns to the 16-line stop_for_relaunch shim the branch wrote (an origin/main merge had replaced it with main's 775-line implementation); the project-metadata tests follow. update_cmd re-exports the four old_updater_deps names the shim tests resolve through hermes_cli.update_cmd.
466 lines
23 KiB
Python
466 lines
23 KiB
Python
"""Offline behavior contracts for PM's caller-owned build operations."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from pm.package import InstallError
|
|
from tests.pm._fixtures import (
|
|
_run,
|
|
_wheel,
|
|
build_worker as build_worker,
|
|
client as client,
|
|
isolated_python as isolated_python,
|
|
served as served,
|
|
)
|
|
|
|
|
|
def test_stage_tools_copies_verified_closure_without_acquiring_or_live_state(tmp_path, client, monkeypatch, served):
|
|
import importlib.util
|
|
import pm
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.registry import _packages
|
|
from pm.store import current_target, tree_digest
|
|
|
|
target = current_target()
|
|
source = tmp_path / "canonical"
|
|
source.mkdir()
|
|
lock = Lockfile(tmp_path / "lock.json")
|
|
facts = Facts(source / "facts.json")
|
|
definition = tmp_path / "copy_fixture.py"
|
|
definition.write_text(
|
|
"from pm.package import Package\n"
|
|
"class CopyLeaf(Package):\n"
|
|
" name = 'copy-leaf'\n"
|
|
" def env(self, entry, target): return {'COPY_ROOT': str(entry)}\n"
|
|
"class CopyTool(CopyLeaf):\n"
|
|
" name = 'copy-tool'\n"
|
|
" deps = ('copy-leaf',)\n", encoding="utf-8")
|
|
spec = importlib.util.spec_from_file_location("copy_fixture", definition)
|
|
module = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, "copy_fixture", module)
|
|
spec.loader.exec_module(module)
|
|
for package in (module.CopyLeaf(), module.CopyTool()):
|
|
monkeypatch.setitem(_packages, package.name, package)
|
|
lock.set_pin(package.name, "1.0", {target: {"url": "https://invalid.test/tool.tgz", "sha256": "a" * 64}})
|
|
entry = source / package.store_entry("1.0", target)
|
|
entry.mkdir()
|
|
(entry / "data").write_text(package.name, encoding="utf-8")
|
|
facts.record(package.name, "1.0", entry.name, package.env(entry, target), source,
|
|
target=target, artifacts=["a" * 64], digest=tree_digest(entry))
|
|
lock.save()
|
|
from tests.pm._fixtures import make_tar
|
|
import shutil
|
|
directory, base = served
|
|
for name in ("copy-leaf", "copy-tool"):
|
|
archive, sha = make_tar(directory, name + ".tgz", {"data": name})
|
|
lock.set_pin(name, "1.0", {target: {"url": base + "/" + archive, "sha256": sha}})
|
|
lock.save()
|
|
shutil.rmtree(source)
|
|
assert pm.prepare_tools(["copy-tool"], out=source, target=target, cache=tmp_path / "python-cache") == source
|
|
facts = Facts(source / "facts.json")
|
|
before = (source / "facts.json").read_bytes()
|
|
home_before = sorted(str(path) for path in (tmp_path / "home").rglob("*"))
|
|
destination = tmp_path / "payload-tools"
|
|
result = pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
|
|
assert result == destination
|
|
# An old output may have been populated with hardlinks. A fresh staging
|
|
# request must establish independent bytes, not reuse those current facts.
|
|
tool_entry = facts.get("copy-tool")["entry"]
|
|
linked_copy = destination / tool_entry / "data"
|
|
linked_copy.unlink()
|
|
os.link(source / tool_entry / "data", linked_copy)
|
|
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
|
|
staged = Facts(destination / "facts.json")
|
|
for name in ("copy-leaf", "copy-tool"):
|
|
entry = facts.get(name)["entry"]
|
|
copied, original = destination / entry / "data", source / entry / "data"
|
|
assert copied.read_bytes() == original.read_bytes()
|
|
assert not copied.samefile(original)
|
|
copied.write_text("signed output", encoding="utf-8")
|
|
assert original.read_text() == name
|
|
assert staged.get(name)["digest"] == facts.get(name)["digest"]
|
|
assert staged.env_for(name, destination)["COPY_ROOT"] == str(destination / entry)
|
|
assert (source / "facts.json").read_bytes() == before
|
|
assert sorted(str(path) for path in (tmp_path / "home").rglob("*")) == home_before
|
|
assert not (tmp_path / "home/config.yaml").exists()
|
|
assert not (tmp_path / "store/facts.json").exists()
|
|
|
|
# Every call must admit the source, even if destination facts are warm.
|
|
original = source / facts.get("copy-tool")["entry"] / "data"
|
|
original.write_text("corrupt", encoding="utf-8")
|
|
with pytest.raises(InstallError, match="source failed verification"):
|
|
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
|
|
original.write_text("copy-tool", encoding="utf-8")
|
|
lock.set_pin("copy-tool", "1.0", {target: {"url": "https://invalid.test/repin", "sha256": "b" * 64}})
|
|
lock.save()
|
|
with pytest.raises(InstallError, match="source failed verification"):
|
|
pm.stage_tools(["copy-tool"], source_store=source, out=destination, target=target)
|
|
lock.set_pin("copy-tool", "1.0", {target: {"url": "https://invalid.test/tool.tgz", "sha256": facts.get("copy-tool")["artifacts"][0]}})
|
|
lock.save()
|
|
# A locally recorded link must not turn an independent copy into a shared
|
|
# mutable file in the cache or another caller's tree.
|
|
external = tmp_path / "external"
|
|
external.write_text("shared", encoding="utf-8")
|
|
original.unlink()
|
|
original.symlink_to(external)
|
|
package = module.CopyTool()
|
|
entry = original.parent
|
|
facts.record(package.name, "1.0", entry.name, package.env(entry, target), source,
|
|
target=target, artifacts=facts.get("copy-tool")["artifacts"], digest=tree_digest(entry))
|
|
with pytest.raises(InstallError, match="source.*verification|escapes"):
|
|
pm.stage_tools(["copy-tool"], source_store=source, out=tmp_path / "other-output", target=target)
|
|
|
|
|
|
@pytest.mark.parametrize("damage", [None, "entry", "env", "bytes", "pin", "binary"])
|
|
def test_verified_tools_admits_only_locked_entries_without_execution(tmp_path, monkeypatch, damage):
|
|
import subprocess
|
|
from pm import build_operations
|
|
from pm.lock import Facts, Lockfile
|
|
from pm.registry import get_package
|
|
from pm.store import current_target, tree_digest
|
|
|
|
target, store = current_target(), tmp_path / "tools"
|
|
lock = Lockfile(tmp_path / "lock.json")
|
|
package = get_package("python")
|
|
entry = store / package.store_entry("1.0", target)
|
|
binary = package.binary(entry, target)
|
|
assert binary is not None
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_bytes(b"already verified at publication")
|
|
lock.set_pin("python", "1.0", {target: {"url": "https://invalid.test/python", "sha256": "a" * 64}})
|
|
facts = Facts(store / "facts.json")
|
|
env = package.env(entry, target)
|
|
if damage == "entry":
|
|
renamed = entry.with_name("noncanonical")
|
|
entry.rename(renamed)
|
|
entry = renamed
|
|
env = package.env(entry, target)
|
|
if damage == "env":
|
|
env["PATH"] = [str(tmp_path / "unverified")]
|
|
if damage == "binary":
|
|
binary.unlink()
|
|
facts.record("python", "1.0", entry.name, env, store, target=target,
|
|
artifacts=["a" * 64], digest=tree_digest(entry))
|
|
if damage == "bytes":
|
|
binary.write_bytes(b"changed")
|
|
if damage == "pin":
|
|
lock.set_pin("python", "1.0", {target: {"url": "https://invalid.test/python", "sha256": "b" * 64}})
|
|
before = {p.relative_to(store): p.read_bytes() for p in store.rglob("*") if p.is_file()}
|
|
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: pytest.fail("read-only admission executed a process"))
|
|
if damage:
|
|
with pytest.raises(InstallError, match="source failed verification"):
|
|
build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
|
|
else:
|
|
selected = build_operations.verified_tools(["python"], source_store=store, target=target, lock=lock)
|
|
assert selected.entries["python"].path == entry
|
|
assert selected.entries["python"].binary == binary
|
|
assert selected.environment({"PATH": "inherited"})["PATH"] == str(binary.parent) + os.pathsep + "inherited"
|
|
assert {p.relative_to(store): p.read_bytes() for p in store.rglob("*") if p.is_file()} == before
|
|
|
|
|
|
@pytest.fixture
|
|
def build_tools(tmp_path, monkeypatch, build_worker):
|
|
env = {key: value for key, value in os.environ.items()
|
|
if not key.startswith(("UV_", "PYTHON")) and key != "VIRTUAL_ENV"}
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
env.update(HOME=str(home), USERPROFILE=str(home), HERMES_HOME=str(home / ".hermes"))
|
|
monkeypatch.setenv("HERMES_HOME", env["HERMES_HOME"])
|
|
monkeypatch.setattr(Path, "home", lambda: home)
|
|
return env
|
|
|
|
|
|
@pytest.fixture
|
|
def locked_source(tmp_path, build_tools):
|
|
from pm import lock_project
|
|
|
|
wheels = tmp_path / "wheelhouse"
|
|
wheels.mkdir()
|
|
for name in ("base_dep", "chosen_dep", "dev_dep"):
|
|
_wheel(wheels, name)
|
|
source = tmp_path / "source with spaces"
|
|
source.mkdir()
|
|
(source / "pyproject.toml").write_text(
|
|
'[project]\nname="export-root"\nversion="1"\nrequires-python=">=3.11"\n'
|
|
'dependencies=["base-dep==1.0"]\n'
|
|
'[project.optional-dependencies]\nchosen=["chosen-dep==1.0; sys_platform == \'win32\'"]\n'
|
|
'[dependency-groups]\ndev=["dev-dep==1.0"]\n'
|
|
'[tool.uv]\npackage=false\nno-index=true\n'
|
|
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
|
|
)
|
|
lock_project(source, python=Path(sys.executable), cache=tmp_path / "cache",
|
|
env=build_tools, offline=True, explicit=True)
|
|
return source
|
|
|
|
|
|
@pytest.mark.parametrize("lock_state", ["current", "stale", "missing"])
|
|
def test_check_lock_never_changes_source(locked_source, tmp_path, build_tools, lock_state):
|
|
from pm import check_project_lock
|
|
|
|
source = locked_source
|
|
if lock_state == "stale":
|
|
manifest = source / "pyproject.toml"
|
|
manifest.write_text(manifest.read_text().replace('version="1"', 'version="2"'), encoding="utf-8")
|
|
elif lock_state == "missing":
|
|
(source / "uv.lock").unlink()
|
|
before = {p.relative_to(source): p.read_bytes() for p in source.rglob("*") if p.is_file()}
|
|
if lock_state == "current":
|
|
check_project_lock(source, python=Path(sys.executable), cache=tmp_path / "cache",
|
|
env=build_tools, offline=True, explicit=True)
|
|
else:
|
|
with pytest.raises(InstallError):
|
|
check_project_lock(source, python=Path(sys.executable), cache=tmp_path / "cache",
|
|
env=build_tools, offline=True, explicit=True)
|
|
assert {p.relative_to(source): p.read_bytes() for p in source.rglob("*") if p.is_file()} == before
|
|
assert not (source / ".venv").exists()
|
|
|
|
|
|
def test_frozen_export_keeps_markers_and_excludes_build_metadata(locked_source, tmp_path, build_tools):
|
|
from packaging.requirements import Requirement
|
|
from pm import export_requirements
|
|
|
|
source = locked_source
|
|
# Frozen export must use yesterday's resolution, not refresh today's edits.
|
|
manifest = source / "pyproject.toml"
|
|
manifest.write_text(manifest.read_text().replace("base-dep==1.0", "base-dep==2.0"), encoding="utf-8")
|
|
before = {p.name: p.read_bytes() for p in source.iterdir() if p.is_file()}
|
|
out = tmp_path / "export directory" / "requirements.txt"
|
|
export_requirements(source, out, extras=["chosen", "chosen"], python=Path(sys.executable),
|
|
cache=tmp_path / "cache", env=build_tools, explicit=True)
|
|
text = out.read_text(encoding="utf-8")
|
|
requirements = {r.name: r for r in map(Requirement, text.splitlines())}
|
|
assert set(requirements) == {"base-dep", "chosen-dep"}
|
|
assert str(requirements["base-dep"].specifier) == "==1.0"
|
|
marker = requirements["chosen-dep"].marker
|
|
assert marker is not None
|
|
assert marker.evaluate({"sys_platform": "win32"})
|
|
assert not marker.evaluate({"sys_platform": "linux"})
|
|
assert "#" not in text and "--hash" not in text
|
|
assert {p.name: p.read_bytes() for p in source.iterdir() if p.is_file()} == before
|
|
assert not (source / ".venv").exists()
|
|
|
|
|
|
def test_frozen_export_preserves_git_commit_pin(locked_source, tmp_path, build_tools):
|
|
from packaging.requirements import Requirement
|
|
from pm import export_requirements, lock_project
|
|
|
|
dependency = tmp_path / "git-dependency"
|
|
dependency.mkdir()
|
|
(dependency / "pyproject.toml").write_text(
|
|
'[project]\nname="git-dep"\nversion="1.0"\nrequires-python=">=3.11"\n'
|
|
'dependencies=[]\n', encoding="utf-8",
|
|
)
|
|
_run(["git", "init", "--quiet"], cwd=dependency, env=build_tools)
|
|
_run(["git", "add", "pyproject.toml"], cwd=dependency, env=build_tools)
|
|
_run(["git", "-c", "user.name=PM test", "-c", "user.email=pm@example.invalid",
|
|
"-c", "commit.gpgsign=false", "-c", f"core.hooksPath={tmp_path / 'empty-hooks'}",
|
|
"commit", "--quiet", "-m", "fixture"], cwd=dependency, env=build_tools)
|
|
revision = _run(["git", "rev-parse", "HEAD"], cwd=dependency, env=build_tools)
|
|
url = f"git+{dependency.as_uri()}@{revision}"
|
|
manifest = locked_source / "pyproject.toml"
|
|
manifest.write_text(manifest.read_text().replace('"base-dep==1.0"', json.dumps(f"git-dep @ {url}")),
|
|
encoding="utf-8")
|
|
lock_project(locked_source, python=Path(sys.executable), cache=tmp_path / "cache", env=build_tools, explicit=True)
|
|
before = (locked_source / "uv.lock").read_bytes()
|
|
out = tmp_path / "git-requirements.txt"
|
|
export_requirements(locked_source, out, cache=tmp_path / "cache", env=build_tools, explicit=True)
|
|
requirement = Requirement(out.read_text(encoding="utf-8").strip())
|
|
assert requirement.name == "git-dep"
|
|
assert requirement.url == url
|
|
assert (locked_source / "uv.lock").read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize("sealed", [False, True])
|
|
def test_requirements_build_installs_offline_markers_and_seals_only_build_pth(tmp_path, build_tools, sealed):
|
|
from pm import build_requirements_environment
|
|
|
|
wheels = tmp_path / "wheels with spaces"
|
|
wheels.mkdir()
|
|
_wheel(wheels, "leaf_dep")
|
|
_wheel(wheels, "app_dep", requirements=["leaf-dep==1.0"])
|
|
out = tmp_path / "destination with spaces"
|
|
# Ambient project and interpreter settings cannot escape the explicit build.
|
|
(tmp_path / "uv.toml").write_text('required-version="<0.1"\n', encoding="utf-8")
|
|
poison = tmp_path / "poison"
|
|
env = dict(build_tools, UV_PROJECT_ENVIRONMENT=str(poison), VIRTUAL_ENV=str(poison),
|
|
UV_PYTHON=str(poison), UV_CACHE_DIR=str(poison), UV_SYSTEM_PYTHON="true")
|
|
before = dict(os.environ)
|
|
executable = build_requirements_environment(
|
|
["app-dep==1.0; python_version >= '3'", "missing-dep==1.0; python_version < '2'"],
|
|
out=out, python=Path(sys.executable), wheelhouse=wheels, cache=tmp_path / "cache",
|
|
env=env, offline=True, sealed=sealed, explicit=True)
|
|
result = json.loads(_run(
|
|
[str(executable), "-I", "-c", "import sys, json, app_dep, leaf_dep, importlib.util; "
|
|
"print(json.dumps([app_dep.__version__, leaf_dep.__version__, sys.base_prefix, "
|
|
"importlib.util.find_spec('missing_dep') is None]))"], cwd=tmp_path, env=build_tools,
|
|
))
|
|
assert result == ["1.0", "1.0", sys.base_prefix, True]
|
|
assert executable.parent.parent == out
|
|
assert bool(list(out.rglob("_virtualenv.pth"))) is not sealed
|
|
assert not poison.exists()
|
|
assert not Path(build_tools["HERMES_HOME"]).exists()
|
|
assert dict(os.environ) == before
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["create", "install", "check"])
|
|
def test_failed_requirement_build_removes_only_its_candidate(tmp_path, build_tools, failure):
|
|
import zipfile
|
|
from pm import build_requirements_environment
|
|
|
|
wheels = tmp_path / "wheels"
|
|
wheels.mkdir()
|
|
wheel = _wheel(wheels, "app_dep")
|
|
previous = tmp_path / "previous"
|
|
executable = build_requirements_environment(["app-dep==1.0"], out=previous, wheelhouse=wheels,
|
|
env=build_tools, cache=tmp_path / "cache", offline=True, explicit=True)
|
|
previous_cfg = (previous / "pyvenv.cfg").read_bytes()
|
|
python = Path(sys.executable)
|
|
if failure == "create":
|
|
python = tmp_path / "missing-python"
|
|
elif failure == "install":
|
|
wheel.unlink()
|
|
else:
|
|
# The archive carries an extra distribution invisible to resolution.
|
|
# pip check must reject its unsatisfied dependency after installation.
|
|
with zipfile.ZipFile(wheel, "a") as archive:
|
|
archive.writestr("app_dep-1.0.data/purelib/ghost-1.0.dist-info/METADATA",
|
|
"Metadata-Version: 2.1\nName: ghost\nVersion: 1.0\n"
|
|
"Requires-Dist: missing-dep==1.0\n")
|
|
out = tmp_path / "candidate"
|
|
with pytest.raises(InstallError, match="dependency validation" if failure == "check" else None):
|
|
build_requirements_environment(["app-dep==1.0"], out=out, python=python, wheelhouse=wheels,
|
|
env=build_tools, cache=tmp_path / "cold-cache", offline=True, explicit=True)
|
|
assert not out.exists()
|
|
with pytest.raises(FileExistsError, match="already exists"):
|
|
build_requirements_environment(["app-dep==1.0"], out=previous, wheelhouse=wheels,
|
|
env=build_tools, cache=tmp_path / "cache", offline=True, explicit=True)
|
|
assert (previous / "pyvenv.cfg").read_bytes() == previous_cfg
|
|
assert _run([str(executable), "-I", "-c", "import app_dep; print(app_dep.__version__)"],
|
|
cwd=tmp_path, env=build_tools) == "1.0"
|
|
|
|
|
|
def test_wheelhouse_cannot_fall_back_to_index_or_build_source(tmp_path, build_tools):
|
|
import base64
|
|
import io
|
|
import tarfile
|
|
from pm import build_requirements_environment
|
|
|
|
index = tmp_path / "index" / "leaf-dep"
|
|
index.mkdir(parents=True)
|
|
wheel = _wheel(index, "leaf_dep")
|
|
(index / "index.html").write_text(f'<a href="{wheel.name}">{wheel.name}</a>', encoding="utf-8")
|
|
wheels = tmp_path / "wheelhouse"
|
|
wheels.mkdir()
|
|
# A real, self-contained sdist: relaxing binary-only would make it install.
|
|
backend = (
|
|
"from pathlib import Path\nimport base64\n"
|
|
"def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):\n"
|
|
f" Path(wheel_directory, {wheel.name!r}).write_bytes(base64.b64decode("
|
|
f"{base64.b64encode(wheel.read_bytes())!r}))\n"
|
|
f" return {wheel.name!r}\n"
|
|
)
|
|
entries = {
|
|
"pyproject.toml": '[project]\nname="leaf-dep"\nversion="1.0"\n'
|
|
'[build-system]\nrequires=[]\nbuild-backend="backend"\nbackend-path=["."]\n',
|
|
"backend.py": backend,
|
|
}
|
|
with tarfile.open(wheels / "leaf_dep-1.0.tar.gz", "w:gz") as archive:
|
|
for name, text in entries.items():
|
|
data = text.encode("utf-8")
|
|
info = tarfile.TarInfo(f"leaf_dep-1.0/{name}")
|
|
info.size = len(data)
|
|
archive.addfile(info, io.BytesIO(data))
|
|
env = dict(build_tools, UV_DEFAULT_INDEX=index.parent.as_uri())
|
|
out = tmp_path / "candidate"
|
|
with pytest.raises(InstallError):
|
|
build_requirements_environment(["leaf-dep==1.0"], out=out, wheelhouse=wheels,
|
|
env=env, cache=tmp_path / "cache", offline=True, explicit=True)
|
|
assert not out.exists()
|
|
# Both alternate sources really work when there is no wheelhouse restriction.
|
|
for name, settings in (("index", env), ("source", dict(build_tools, UV_NO_INDEX="1", UV_FIND_LINKS=str(wheels)))):
|
|
executable = build_requirements_environment(
|
|
["leaf-dep==1.0"], out=tmp_path / f"from-{name}", env=settings,
|
|
cache=tmp_path / f"{name}-cache", offline=True, explicit=True)
|
|
assert _run([str(executable), "-I", "-c", "import leaf_dep; print(leaf_dep.__version__)"],
|
|
cwd=tmp_path, env=build_tools) == "1.0"
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["check", "export", "build"])
|
|
def test_ready_tools_do_not_bypass_disabled_lazy_operations(locked_source, tmp_path, build_tools, monkeypatch, operation):
|
|
import importlib
|
|
from pm import build_requirements_environment, check_project_lock, export_requirements
|
|
|
|
monkeypatch.setattr(importlib.import_module("pm.install"), "lazy_installs_allowed", lambda: False)
|
|
out = tmp_path / "blocked-output"
|
|
before = {p.name: p.read_bytes() for p in locked_source.iterdir() if p.is_file()}
|
|
actions = {
|
|
"check": lambda: check_project_lock(locked_source, env=build_tools, cache=tmp_path / "cache"),
|
|
"export": lambda: export_requirements(locked_source, out, env=build_tools, cache=tmp_path / "cache"),
|
|
"build": lambda: build_requirements_environment(["base-dep==1.0"], out=out, env=build_tools,
|
|
cache=tmp_path / "cache", offline=True),
|
|
}
|
|
with pytest.raises(InstallError, match="lazy installs are disabled"):
|
|
actions[operation]()
|
|
assert not out.exists()
|
|
assert {p.name: p.read_bytes() for p in locked_source.iterdir() if p.is_file()} == before
|
|
# Passive lock validation is safe with already-ready tools and no network.
|
|
check_project_lock(locked_source, env=build_tools, cache=tmp_path / "cache", offline=True)
|
|
|
|
|
|
def test_prune_cache_does_not_acquire_a_missing_toolchain(tmp_path, monkeypatch):
|
|
import importlib
|
|
import pm.paths
|
|
from pm import prune_cache
|
|
|
|
monkeypatch.setattr("pm.client.is_runtime", lambda: True)
|
|
store = tmp_path / "empty-store"
|
|
monkeypatch.setattr(pm.paths, "store_root", lambda: store)
|
|
monkeypatch.setattr(pm.paths, "writable_store_root", lambda: store)
|
|
monkeypatch.setattr(pm.paths, "facts_path", lambda: store / "facts.json")
|
|
monkeypatch.setattr(importlib.import_module("pm.install"), "ensure",
|
|
lambda *args, **kwargs: pytest.fail("cache pruning must not acquire tools"))
|
|
with pytest.raises(InstallError, match="pinned toolchain is unavailable"):
|
|
prune_cache(tmp_path / "cache")
|
|
assert not store.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("ci", [False, True])
|
|
def test_prune_cache_preserves_downloaded_wheels_unless_ci(tmp_path, build_tools, ci):
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
import threading
|
|
from pm import build_requirements_environment, prune_cache
|
|
|
|
wheels = tmp_path / "wheels"
|
|
wheels.mkdir()
|
|
wheel = _wheel(wheels, "cached_dep")
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(wheels)))
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
cache = tmp_path / "cache"
|
|
requirement = f"cached-dep @ http://127.0.0.1:{server.server_port}/{wheel.name}"
|
|
try:
|
|
executable = build_requirements_environment(
|
|
[requirement], out=tmp_path / "first", cache=cache, env=build_tools, explicit=True)
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|
|
assert _run([str(executable), "-I", "-c", "import cached_dep; print(cached_dep.__version__)"],
|
|
cwd=tmp_path, env=build_tools) == "1.0"
|
|
prune_cache(cache, ci=ci)
|
|
out = tmp_path / "second"
|
|
if ci:
|
|
with pytest.raises(InstallError):
|
|
build_requirements_environment([requirement], out=out, cache=cache, env=build_tools, offline=True, explicit=True)
|
|
assert not out.exists()
|
|
else:
|
|
second = build_requirements_environment([requirement], out=out, cache=cache, env=build_tools, offline=True, explicit=True)
|
|
assert _run([str(second), "-I", "-c", "import cached_dep; print(cached_dep.__version__)"],
|
|
cwd=tmp_path, env=build_tools) == "1.0"
|