597 lines
22 KiB
Python
597 lines
22 KiB
Python
"""Focused coverage for concrete Python dependency-manifest editors and their composite."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
from subprocess import CompletedProcess
|
|
from collections.abc import Iterable, Sequence
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from band_aider.strategies.bom import ReferenceOperation
|
|
from band_aider.strategies.python.python_bom_editor import PythonBomEditor
|
|
from band_aider.strategies.python.python_toml_bom_editor import (
|
|
PythonTomlBomEditor,
|
|
)
|
|
from band_aider.strategies.python.python_requires_bom_editor import (
|
|
PythonRequiresBomEditor,
|
|
)
|
|
from band_aider.strategies.python.python_setup_py_bom_editor import (
|
|
PythonSetupPyBomEditor,
|
|
)
|
|
from tests.support.memory_path import MemoryPath
|
|
from tests.support.memory_repo_exec import MemoryRepoExec
|
|
|
|
|
|
def _exec(tmp_path: MemoryPath) -> MemoryRepoExec:
|
|
"""Create the only filesystem boundary used by these strategy tests."""
|
|
|
|
return MemoryRepoExec(tmp_path)
|
|
|
|
|
|
class _PipRepoExec(MemoryRepoExec):
|
|
"""Memory repository with stationary Git and pip validation results."""
|
|
|
|
def __init__(self, repo_root: Path) -> None:
|
|
super().__init__(repo_root)
|
|
self.pip_commands: list[list[str]] = []
|
|
self.pip_results: list[CompletedProcess[str]] = []
|
|
self._baseline_files: dict[Path, bytes] = {}
|
|
|
|
def capture_clean_manifest_baseline(self) -> None:
|
|
"""Mark the current in-memory manifest files as tracked and clean."""
|
|
|
|
self._baseline_files = dict(self._files)
|
|
|
|
def run_cli(
|
|
self,
|
|
command: Sequence[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
env: dict[str, str] | None = None,
|
|
capture_output: bool = True,
|
|
text: bool = True,
|
|
check: bool = False,
|
|
timeout: int | None = None,
|
|
input: bytes | str | None = None,
|
|
unset_env: Iterable[str] = (),
|
|
) -> subprocess.CompletedProcess:
|
|
"""Return stationary Git/pip results without launching a host command."""
|
|
|
|
normalized = list(command)
|
|
if normalized[1:3] == ["-m", "pip"]:
|
|
self.pip_commands.append(normalized)
|
|
if self.pip_results:
|
|
return self.pip_results.pop(0)
|
|
return CompletedProcess(normalized, 0, "", "")
|
|
|
|
if normalized[:2] == ["git", "ls-files"]:
|
|
path = self.root / normalized[-1]
|
|
return CompletedProcess(normalized, 0 if path in self._baseline_files else 1, "", "")
|
|
if normalized[:3] == ["git", "status", "--porcelain"]:
|
|
path = self.root / normalized[-1]
|
|
baseline = self._baseline_files.get(path)
|
|
current = self._files.get(path)
|
|
output = "" if baseline == current else f" M {normalized[-1]}\n"
|
|
return CompletedProcess(normalized, 0, output, "")
|
|
if normalized[:2] == ["git", "checkout"]:
|
|
path = self.root / normalized[-1]
|
|
baseline = self._baseline_files.get(path)
|
|
if baseline is None:
|
|
return CompletedProcess(normalized, 1, "", "missing baseline")
|
|
self.write_bytes(path, baseline)
|
|
return CompletedProcess(normalized, 0, "", "")
|
|
return super().run_cli(
|
|
command,
|
|
cwd=cwd,
|
|
env=env,
|
|
capture_output=capture_output,
|
|
text=text,
|
|
check=check,
|
|
timeout=timeout,
|
|
input=input,
|
|
unset_env=unset_env,
|
|
)
|
|
|
|
|
|
class _FallbackPythonTomlBomEditor(PythonTomlBomEditor):
|
|
"""Exercise the owned literal parser without altering module-level imports."""
|
|
|
|
def _load_toml(self, source: str) -> dict[str, object]:
|
|
"""Use the editor's safe fallback for this in-memory compatibility case."""
|
|
|
|
return self._fallback_project_toml(source)
|
|
|
|
|
|
def _pip_exec(tmp_path: MemoryPath) -> _PipRepoExec:
|
|
"""Create a repo adapter that proves pip stays behind RepoExec."""
|
|
|
|
return _PipRepoExec(tmp_path)
|
|
|
|
|
|
def _commit_manifests(repo_exec: _PipRepoExec, root: Path) -> None:
|
|
"""Capture a clean tracked memory baseline for directive-mutation coverage."""
|
|
|
|
del root
|
|
repo_exec.capture_clean_manifest_baseline()
|
|
|
|
|
|
def test_requirements_parses_pep508_lines_and_preserves_directives(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "requirements.txt"
|
|
repo_exec.write_text(
|
|
path,
|
|
"# project requirements\n"
|
|
"Requests[socks]>=2 ; python_version >= '3.10' # HTTP client\n"
|
|
"PyYAML==6.0 --hash=sha256:abc\n"
|
|
"-r base.txt\n"
|
|
"git+https://example.invalid/private.git\n",
|
|
)
|
|
repo_exec.write_text(tmp_path / "base.txt", "httpx>=0.27\n")
|
|
strategy = PythonRequiresBomEditor(path, repo_exec)
|
|
|
|
references = strategy.inventory()
|
|
|
|
assert [(item.name, item.specifier) for item in references] == [
|
|
("requests", "[socks]>=2; python_version >= \"3.10\""),
|
|
("pyyaml", "==6.0"),
|
|
("httpx", ">=0.27"),
|
|
]
|
|
assert references[1].import_names == ("yaml", "pyyaml")
|
|
|
|
strategy.apply(ReferenceOperation("UPDATE_REF", "requests", "2.31"))
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", ">=13"))
|
|
result = repo_exec.read_text(path)
|
|
assert "requests==2.31 # HTTP client" in result
|
|
assert "rich>=13" in result
|
|
assert "-r base.txt" in result
|
|
assert "git+https://example.invalid/private.git" in result
|
|
|
|
strategy.apply(ReferenceOperation("REMOVE_REF", "rich"))
|
|
assert "rich" not in repo_exec.read_text(path)
|
|
|
|
with pytest.raises(ValueError, match="already declared"):
|
|
strategy.apply(ReferenceOperation("ADD_REF", "httpx", "0.28"))
|
|
|
|
|
|
def test_requirements_uses_last_duplicate_and_replaces_it_on_update(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "requirements.txt"
|
|
repo_exec.write_text(path, "Requests==1\nrequests==2\n")
|
|
strategy = PythonRequiresBomEditor(path, repo_exec)
|
|
|
|
assert [(reference.name, reference.specifier) for reference in strategy.inventory()] == [
|
|
("requests", "==2"),
|
|
]
|
|
strategy.apply(ReferenceOperation("UPDATE_REF", "requests", "3"))
|
|
assert repo_exec.read_text(path) == "requests==3\n"
|
|
with pytest.raises(ValueError, match="Invalid version/specifier"):
|
|
strategy.apply(ReferenceOperation("ADD_REF", "other", "=>2"))
|
|
|
|
|
|
def test_setup_py_uses_literal_ast_and_never_executes_manifest(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
path,
|
|
"from setuptools import setup\n"
|
|
"raise RuntimeError('this setup.py must never execute')\n"
|
|
"setup(\n"
|
|
" name='demo',\n"
|
|
" install_requires=('Requests>=2', 'PyYAML==6'),\n"
|
|
")\n",
|
|
)
|
|
strategy = PythonSetupPyBomEditor(path, repo_exec)
|
|
|
|
assert strategy.is_editable()
|
|
assert [item.name for item in strategy.inventory()] == ["requests", "pyyaml"]
|
|
strategy.apply(ReferenceOperation("UPDATE_REF", "requests", ">=2.1"))
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", "13.0"))
|
|
source = repo_exec.read_text(path)
|
|
ast.parse(source)
|
|
assert "raise RuntimeError" in source
|
|
assert "rich==13.0" in source
|
|
|
|
|
|
def test_setup_py_mutation_handles_utf8_before_literal_span(tmp_path: MemoryPath) -> None:
|
|
"""AST byte columns do not corrupt a manifest containing Unicode metadata."""
|
|
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
path,
|
|
"from setuptools import setup\n"
|
|
"setup(name='café', install_requires=['requests>=2'])\n",
|
|
)
|
|
strategy = PythonSetupPyBomEditor(path, repo_exec)
|
|
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
|
|
source = repo_exec.read_text(path)
|
|
ast.parse(source)
|
|
assert "name='café'" in source
|
|
assert "requests>=2" in source
|
|
assert "rich==13" in source
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("editor_type", "filename", "source"),
|
|
(
|
|
(
|
|
PythonSetupPyBomEditor,
|
|
"setup.py",
|
|
"from setuptools import setup\n"
|
|
"setup(install_requires=['requests==1', 'requests==2'])\n",
|
|
),
|
|
(
|
|
PythonTomlBomEditor,
|
|
"pyproject.toml",
|
|
"[project]\n"
|
|
"dependencies = ['requests==1', 'requests==2']\n",
|
|
),
|
|
),
|
|
)
|
|
def test_literal_manifest_editors_use_last_duplicate_declaration(
|
|
tmp_path: MemoryPath,
|
|
editor_type,
|
|
filename: str,
|
|
source: str,
|
|
) -> None:
|
|
"""Updating a duplicated requirement replaces its final effective declaration."""
|
|
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / filename
|
|
repo_exec.write_text(path, source)
|
|
editor = editor_type(path, repo_exec)
|
|
|
|
assert [(reference.name, reference.specifier) for reference in editor.inventory()] == [
|
|
("requests", "==2"),
|
|
]
|
|
editor.apply(ReferenceOperation("UPDATE_REF", "requests", "3"))
|
|
assert [(reference.name, reference.specifier) for reference in editor.inventory()] == [
|
|
("requests", "==3"),
|
|
]
|
|
|
|
|
|
def test_setup_py_dynamic_install_requires_is_not_safely_mutable(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
path,
|
|
"from setuptools import setup\n"
|
|
"deps = ['requests>=2']\n"
|
|
"setup(name='demo', install_requires=deps)\n",
|
|
)
|
|
strategy = PythonSetupPyBomEditor(path, repo_exec)
|
|
|
|
assert not strategy.is_editable()
|
|
assert strategy.inventory() == []
|
|
with pytest.raises(ValueError, match="literal list or tuple"):
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
|
|
|
|
def test_setup_py_only_accepts_statically_imported_setup_bindings(tmp_path: MemoryPath) -> None:
|
|
"""An arbitrary ``object.setup`` call must never be treated as a manifest."""
|
|
|
|
repo_exec = _exec(tmp_path)
|
|
unsafe = tmp_path / "unsafe_setup.py"
|
|
repo_exec.write_text(
|
|
unsafe,
|
|
"class Pretender:\n"
|
|
" def setup(self, **kwargs):\n"
|
|
" raise RuntimeError('must never be trusted')\n"
|
|
"Pretender().setup(install_requires=['requests>=2'])\n",
|
|
)
|
|
unsafe_strategy = PythonSetupPyBomEditor(unsafe, repo_exec)
|
|
|
|
assert not unsafe_strategy.is_editable()
|
|
assert unsafe_strategy.inventory() == []
|
|
with pytest.raises(ValueError, match="no install_requires"):
|
|
unsafe_strategy.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
|
|
safe = tmp_path / "safe_setup.py"
|
|
repo_exec.write_text(
|
|
safe,
|
|
"import setuptools as packaging\n"
|
|
"packaging.setup(install_requires=['requests>=2'])\n",
|
|
)
|
|
safe_strategy = PythonSetupPyBomEditor(safe, repo_exec)
|
|
|
|
assert safe_strategy.is_editable()
|
|
safe_strategy.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
assert "rich==13" in repo_exec.read_text(safe)
|
|
|
|
|
|
def test_pyproject_static_pep621_crud_and_missing_dependencies_insertion(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "pyproject.toml"
|
|
repo_exec.write_text(
|
|
path,
|
|
"[project]\n"
|
|
"name = 'demo'\n"
|
|
"dependencies = [\n"
|
|
" 'Requests>=2', # keep surrounding project metadata\n"
|
|
" 'PyYAML==6',\n"
|
|
"]\n"
|
|
"\n[tool.example]\n"
|
|
"enabled = true\n",
|
|
)
|
|
strategy = PythonTomlBomEditor(path, repo_exec)
|
|
|
|
assert strategy.is_editable()
|
|
assert [item.name for item in strategy.inventory()] == ["requests", "pyyaml"]
|
|
strategy.apply(ReferenceOperation("UPDATE_REF", "requests", "2.31"))
|
|
strategy.apply(ReferenceOperation("REMOVE_REF", "pyyaml"))
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", ">=13"))
|
|
source = repo_exec.read_text(path)
|
|
assert '"requests==2.31"' in source
|
|
assert '"rich>=13"' in source
|
|
assert "[tool.example]" in source
|
|
|
|
no_dependencies = tmp_path / "without-dependencies.toml"
|
|
repo_exec.write_text(no_dependencies, "[project]\nname = 'other'\n")
|
|
empty = PythonTomlBomEditor(no_dependencies, repo_exec)
|
|
assert empty.is_editable()
|
|
empty.apply(ReferenceOperation("ADD_REF", "httpx", "0.27"))
|
|
assert "dependencies = [" in repo_exec.read_text(no_dependencies)
|
|
assert '"httpx==0.27"' in repo_exec.read_text(no_dependencies)
|
|
|
|
|
|
def test_pyproject_dynamic_dependencies_are_read_only(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "pyproject.toml"
|
|
repo_exec.write_text(path, "[project]\ndynamic = ['dependencies']\n")
|
|
strategy = PythonTomlBomEditor(path, repo_exec)
|
|
|
|
assert not strategy.is_editable()
|
|
assert strategy.inventory() == []
|
|
with pytest.raises(ValueError, match="dynamic dependencies"):
|
|
strategy.apply(ReferenceOperation("ADD_REF", "httpx", "0.27"))
|
|
|
|
|
|
def test_pyproject_uses_safe_literal_fallback_when_no_toml_library(tmp_path: MemoryPath) -> None:
|
|
"""A literal project dependency declaration stays editable in memory."""
|
|
|
|
repo_exec = _exec(tmp_path)
|
|
path = tmp_path / "pyproject.toml"
|
|
repo_exec.write_text(
|
|
path,
|
|
"[project]\n"
|
|
"dependencies = [\n"
|
|
" 'requests>=2', # comments remain parseable\n"
|
|
"]\n",
|
|
)
|
|
strategy = _FallbackPythonTomlBomEditor(path, repo_exec)
|
|
|
|
assert [item.name for item in strategy.inventory()] == ["requests"]
|
|
strategy.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
assert '"rich==13"' in repo_exec.read_text(path)
|
|
|
|
|
|
def test_editor_uses_selected_manifest_and_rejects_invalid_operation(tmp_path: MemoryPath) -> None:
|
|
repo_exec = _pip_exec(tmp_path)
|
|
requirements = tmp_path / "requirements.txt"
|
|
setup = tmp_path / "setup.py"
|
|
repo_exec.write_text(requirements, "requests>=2\n")
|
|
repo_exec.write_text(
|
|
setup,
|
|
"from setuptools import setup\nsetup(install_requires=['requests>=2'])\n",
|
|
)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
assert editor.selected_editor.path == setup
|
|
assert [item.name for item in editor.inventory()] == ["requests"]
|
|
before = {
|
|
path: repo_exec.read_text(path)
|
|
for path in (requirements, setup)
|
|
}
|
|
with pytest.raises(ValueError, match="Unknown dependency operation"):
|
|
editor.apply(ReferenceOperation("NOT_A_DIRECTIVE", "ignored", "1"))
|
|
assert {
|
|
path: repo_exec.read_text(path)
|
|
for path in (requirements, setup)
|
|
} == before
|
|
|
|
# A lower-priority dev manifest does not redefine production availability.
|
|
repo_exec.write_text(requirements, "requests>=3\n")
|
|
assert [(item.name, item.specifier) for item in editor.inventory()] == [
|
|
("requests", ">=2"),
|
|
]
|
|
|
|
|
|
def test_editor_applies_operations_against_the_authoritative_manifest(tmp_path: MemoryPath) -> None:
|
|
"""Aggregate inventory cannot make an UPDATE mutable in the wrong manifest."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
repo_exec.write_text(tmp_path / "requirements.txt", "requests>=2\n")
|
|
setup = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
setup,
|
|
"from setuptools import setup\nsetup(install_requires=['httpx>=0.27'])\n",
|
|
)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
before = repo_exec.read_text(setup)
|
|
|
|
with pytest.raises(ValueError, match="Dependency is not declared"):
|
|
editor.apply(ReferenceOperation("UPDATE_REF", "requests", ">=2.1"))
|
|
|
|
assert repo_exec.read_text(setup) == before
|
|
|
|
|
|
def test_editor_selection_is_setup_then_pyproject_then_requirements(tmp_path: MemoryPath) -> None:
|
|
"""The first existing supported manifest owns Bootstrap mutation and inventory."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
repo_exec.write_text(tmp_path / "requirements.txt", "pytest>=8\n")
|
|
repo_exec.write_text(
|
|
tmp_path / "pyproject.toml",
|
|
"[project]\nname = 'demo'\ndependencies = ['httpx>=0.27']\n",
|
|
)
|
|
repo_exec.write_text(
|
|
tmp_path / "setup.py",
|
|
"from setuptools import setup\nsetup(install_requires=['requests>=2'])\n",
|
|
)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
assert editor.selected_editor.path == tmp_path / "setup.py"
|
|
assert [item.name for item in editor.inventory()] == ["requests"]
|
|
assert [item.name for item in editor.inventory(include_development=True)] == [
|
|
"requests",
|
|
"pytest",
|
|
]
|
|
|
|
|
|
def test_editor_does_not_fall_through_an_unsupported_higher_priority_manifest(
|
|
tmp_path: MemoryPath,
|
|
) -> None:
|
|
"""A dynamic setup.py must fail clearly rather than mutate pyproject.toml."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
repo_exec.write_text(
|
|
tmp_path / "setup.py",
|
|
"from setuptools import setup\ndeps = ['requests>=2']\nsetup(install_requires=deps)\n",
|
|
)
|
|
repo_exec.write_text(
|
|
tmp_path / "pyproject.toml",
|
|
"[project]\nname = 'demo'\ndependencies = ['httpx>=0.27']\n",
|
|
)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
with pytest.raises(RuntimeError, match="cannot be safely mutated"):
|
|
_ = editor.selected_editor
|
|
|
|
|
|
def test_editor_requires_a_clean_tracked_manifest_and_returns_action_readout(tmp_path: MemoryPath) -> None:
|
|
"""Directive mutation never overwrites user changes or expose a rollback receipt."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
setup = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
setup,
|
|
"from setuptools import setup\nsetup(install_requires=['requests>=2'])\n",
|
|
)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
readout = editor.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
assert "Updated setup.py: ADD_REF::rich::13" in readout
|
|
assert "Installed rich==13 with pip" in readout
|
|
assert "rich==13" in repo_exec.read_text(setup)
|
|
assert repo_exec.pip_commands == [
|
|
[repo_exec.python_bin(), "-m", "pip", "install", "rich==13"],
|
|
[repo_exec.python_bin(), "-m", "pip", "check"],
|
|
]
|
|
assert not hasattr(editor, "rollback")
|
|
|
|
repo_exec.write_text(setup, "# user change\n" + repo_exec.read_text(setup))
|
|
with pytest.raises(RuntimeError, match="existing Git changes"):
|
|
editor.apply(ReferenceOperation("ADD_REF", "httpx", "0.27"))
|
|
|
|
|
|
def test_editor_reference_operations_are_target_state_idempotent(tmp_path: MemoryPath) -> None:
|
|
"""Repeated exact directives become no-ops before Git mutation preflight."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
setup = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
setup,
|
|
"from setuptools import setup\nsetup(install_requires=['requests>=2'])\n",
|
|
)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
assert "Updated setup.py: ADD_REF::rich::13" in editor.apply(
|
|
ReferenceOperation("ADD_REF", "rich", "13")
|
|
)
|
|
assert editor.apply(ReferenceOperation("ADD_REF", "rich", "13")) == (
|
|
"No change in setup.py: ADD_REF::rich::13"
|
|
)
|
|
assert editor.apply(ReferenceOperation("UPDATE_REF", "rich", "13")) == (
|
|
"No change in setup.py: UPDATE_REF::rich::13"
|
|
)
|
|
assert editor.apply(ReferenceOperation("REMOVE_REF", "missing")) == (
|
|
"No change in setup.py: REMOVE_REF::missing"
|
|
)
|
|
assert len(repo_exec.pip_commands) == 2
|
|
with pytest.raises(ValueError, match="Use UPDATE_REF"):
|
|
editor.apply(ReferenceOperation("ADD_REF", "rich", "14"))
|
|
|
|
|
|
def test_editor_reparses_manifest_between_operations(tmp_path: MemoryPath) -> None:
|
|
"""A batch follows the manifest's evolving state rather than directive history."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
setup = tmp_path / "setup.py"
|
|
repo_exec.write_text(
|
|
setup,
|
|
"from setuptools import setup\nsetup(install_requires=['requests>=2'])\n",
|
|
)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
editor.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
editor.apply(ReferenceOperation("REMOVE_REF", "rich"))
|
|
editor.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
|
|
assert [reference.name for reference in editor.inventory()] == ["requests", "rich"]
|
|
assert len(repo_exec.pip_commands) == 4
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pip_results", "failed_command", "stdout", "stderr"),
|
|
(
|
|
(
|
|
(CompletedProcess([], 1, "resolver output", "install output"),),
|
|
"install rich==13",
|
|
"resolver output",
|
|
"install output",
|
|
),
|
|
(
|
|
(
|
|
CompletedProcess([], 0, "", ""),
|
|
CompletedProcess([], 1, "check output", "dependency conflict"),
|
|
),
|
|
"check",
|
|
"check output",
|
|
"dependency conflict",
|
|
),
|
|
),
|
|
)
|
|
def test_editor_rolls_back_selected_manifest_when_pip_validation_fails(
|
|
tmp_path: MemoryPath,
|
|
pip_results: tuple[CompletedProcess[str], ...],
|
|
failed_command: str,
|
|
stdout: str,
|
|
stderr: str,
|
|
) -> None:
|
|
"""Pip install/check errors restore the clean tracked selected manifest via Git."""
|
|
|
|
repo_exec = _pip_exec(tmp_path)
|
|
setup = tmp_path / "setup.py"
|
|
baseline = "from setuptools import setup\nsetup(install_requires=['requests>=2'])\n"
|
|
repo_exec.write_text(setup, baseline)
|
|
_commit_manifests(repo_exec, tmp_path)
|
|
repo_exec.pip_results.extend(pip_results)
|
|
editor = PythonBomEditor(tmp_path, repo_exec)
|
|
|
|
with pytest.raises(RuntimeError) as error:
|
|
editor.apply(ReferenceOperation("ADD_REF", "rich", "13"))
|
|
|
|
message = str(error.value)
|
|
assert "Python dependency validation failed." in message
|
|
assert f"COMMAND: {repo_exec.python_bin()} -m pip {failed_command}" in message
|
|
assert "EXIT CODE: 1" in message
|
|
assert f"STDOUT:\n{stdout}" in message
|
|
assert f"STDERR:\n{stderr}" in message
|
|
assert repo_exec.read_text(setup) == baseline
|
|
status = repo_exec.run_cli(
|
|
["git", "status", "--porcelain", "--", "setup.py"],
|
|
cwd=tmp_path,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert status.stdout == ""
|