band-aider-pub/tests/band_aider/test_python_bom_editors.py
2026-10-17 11:57:37 -07:00

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 == ""