diff --git a/pm/operations.py b/pm/operations.py index 162525ac0e..b07358e7a4 100644 --- a/pm/operations.py +++ b/pm/operations.py @@ -18,6 +18,13 @@ import uuid from pm.package import InstallError +def _require_install_allowed(explicit: bool) -> None: + from pm.ensure import _refuse_lazy, lazy_installs_allowed + + if not explicit and not lazy_installs_allowed(): + raise _refuse_lazy("venv", "Python dependency operation requires an explicit request") + + def build_environment( *, source: Path, out: Path, python: Path | None = None, cache: Path | None = None, env: Mapping[str, str] | None = None, @@ -41,6 +48,7 @@ def build_environment( raise InstallError("venv", f"frozen build requires a lock: {source / 'uv.lock'}") if out.exists() or out.is_symlink(): raise FileExistsError(f"environment destination already exists: {out}") + _require_install_allowed(explicit) environment = managed_environment( out, python=Path(python) if python is not None else None, cache=Path(cache) if cache is not None else None, env=env, @@ -71,6 +79,7 @@ def lock_project( source = Path(source).absolute() if not (source / "pyproject.toml").is_file(): raise InstallError("venv", f"project manifest is missing: {source}") + _require_install_allowed(explicit) environment = managed_environment( source / ".venv", python=Path(python) if python is not None else None, cache=Path(cache) if cache is not None else None, env=env, diff --git a/tests/pm/test_environment_build.py b/tests/pm/test_environment_build.py index db8ee2578c..2f871b747e 100644 --- a/tests/pm/test_environment_build.py +++ b/tests/pm/test_environment_build.py @@ -152,7 +152,7 @@ def test_public_build_installs_all_extras_at_explicit_destination(installable_pr monkeypatch.setattr(pm.workspace, "enabled_member_dirs", lambda: pytest.fail("user plugins")) before = dict(os.environ) locked = (source / "uv.lock").read_bytes() - executable = build_environment( + executable = build_environment(explicit=True, source=source, python=Path(sys.executable), out=tmp_path / "native environment", cache=tmp_path / "cache", env=env, all_extras=True, offline=True, sealed=sealed, @@ -183,7 +183,7 @@ def test_public_dependency_only_build_needs_no_application_source(installable_pr locked = (source / "uv.lock").read_bytes() from pm import build_environment - executable = build_environment(source=source, python=Path(sys.executable), + executable = build_environment(explicit=True, source=source, python=Path(sys.executable), out=tmp_path / "docker env", cache=tmp_path / "cache", env=env, no_install_project=True, offline=True, **selection) assert executable.is_file() @@ -323,14 +323,14 @@ def test_failed_build_removes_only_its_candidate(installable_project, tmp_path, source, uv, env = installable_project previous = tmp_path / "previous" - executable = build_environment(source=source, python=Path(sys.executable), + executable = build_environment(explicit=True, source=source, python=Path(sys.executable), out=previous, env=env, cache=tmp_path / "cache", offline=True) cfg = (previous / "pyvenv.cfg").read_bytes() source_lock = (source / "uv.lock").read_bytes() # Check destination refusal with valid inputs. The contract does not specify # which error comes first when the source is also damaged. with pytest.raises(FileExistsError): - build_environment(source=source, python=Path(sys.executable), + build_environment(explicit=True, source=source, python=Path(sys.executable), out=previous, env=env, cache=tmp_path / "cache", offline=True) if damage == "source": (source / "root_app.py").unlink() @@ -343,7 +343,7 @@ def test_failed_build_removes_only_its_candidate(installable_project, tmp_path, (source / "uv.lock").unlink() candidate = tmp_path / "candidate" with pytest.raises(InstallError, match="dependency validation" if damage == "check" else "uv sync|frozen build requires a lock"): - build_environment(source=source, python=Path(sys.executable), + build_environment(explicit=True, source=source, python=Path(sys.executable), out=candidate, env=env, cache=tmp_path / "cold-cache", offline=True) assert not candidate.exists() assert (previous / "pyvenv.cfg").read_bytes() == cfg