diff --git a/CLAUDE.md b/CLAUDE.md index 80fb703..d2fbd61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ pact-mcp # MCP server entry point **Entry point**: `pact = "pact.cli:main"`, `pact-mcp = "pact.mcp_server:main"` (pyproject.toml) -**Python**: >=3.12 | **Dependencies**: pydantic>=2.0, pyyaml>=6.0 | **Optional**: anthropic>=0.40, mcp>=1.0 +**Python**: >=3.12 | **Dependencies**: pydantic>=2.0, pytest>=8.2, pyyaml>=6.0 | **Optional**: anthropic>=0.40, mcp>=1.0 ## Architecture Overview diff --git a/pyproject.toml b/pyproject.toml index bb1fcd4..dde06ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", + "pytest>=8.2,<10", "pyyaml>=6.0", ] @@ -67,7 +68,6 @@ transmogrifier = [ "transmogrifier>=0.2.0", ] dev = [ - "pytest>=8.2,<10", "pytest-asyncio>=1.3,<2", ] diff --git a/src/pact/certification.py b/src/pact/certification.py index e335f8d..6195fd2 100644 --- a/src/pact/certification.py +++ b/src/pact/certification.py @@ -172,6 +172,7 @@ async def certify(project: ProjectManager) -> CertificationArtifact: results = await run_contract_tests( test_file, impl_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) cert.visible_results[cid] = { "total": results.total, @@ -198,6 +199,7 @@ async def certify(project: ProjectManager) -> CertificationArtifact: results = await run_contract_tests( test_file, impl_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) cert.goodhart_results[cid] = { "total": results.total, @@ -242,6 +244,7 @@ async def certify(project: ProjectManager) -> CertificationArtifact: results = await run_contract_tests( test_file, impl_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) cert.emission_results[cid] = { "total": results.total, diff --git a/src/pact/config.py b/src/pact/config.py index 7281222..43f7db0 100644 --- a/src/pact/config.py +++ b/src/pact/config.py @@ -8,6 +8,7 @@ import logging import os +import sys from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path @@ -433,7 +434,9 @@ def resolve_parallel_config( @dataclass class EnvironmentSpec: """Standardized execution environment for test harness and agents.""" - python_path: str = "python3" + # Defaults to the interpreter running pact, so the test run sees the same + # Python version and installed packages. + python_path: str = sys.executable inherit_path: bool = True extra_path_dirs: list[str] = field(default_factory=list) required_tools: list[str] = field(default_factory=lambda: ["pytest"]) @@ -575,7 +578,7 @@ def resolve_environment(project: ProjectConfig, global_cfg: GlobalConfig) -> Env if not raw: return EnvironmentSpec() return EnvironmentSpec( - python_path=raw.get("python_path", "python3"), + python_path=raw.get("python_path", sys.executable), inherit_path=raw.get("inherit_path", True), extra_path_dirs=raw.get("extra_path_dirs", []), required_tools=raw.get("required_tools", ["pytest"]), diff --git a/src/pact/implementer.py b/src/pact/implementer.py index 338a487..4c86c25 100644 --- a/src/pact/implementer.py +++ b/src/pact/implementer.py @@ -687,6 +687,7 @@ async def implement_component( test_results = await run_contract_tests( test_file, src_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) last_test_results = test_results project.save_test_results(component_id, test_results) @@ -947,6 +948,7 @@ async def implement_component_iterative( test_results = await run_contract_tests( test_file, src_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) project.save_test_results(component_id, test_results) @@ -1101,6 +1103,7 @@ async def implement_component_interactive( test_results = await run_contract_tests( test_file, src_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) project.save_test_results(component_id, test_results) @@ -1179,6 +1182,7 @@ async def _run_one_competitor( test_results = await run_contract_tests( test_file, src_dir, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) last_test_results = test_results project.save_attempt_test_results(component_id, attempt_id, test_results) diff --git a/src/pact/integrator.py b/src/pact/integrator.py index 53541fb..6e06b46 100644 --- a/src/pact/integrator.py +++ b/src/pact/integrator.py @@ -191,6 +191,7 @@ class GlueResponse(BaseModel): test_file, comp_dir, extra_paths=child_paths, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) # Save results to internal composition dir @@ -558,6 +559,7 @@ async def integrate_component_iterative( test_file, comp_dir, extra_paths=child_paths, language=language, project_dir=project.project_dir, + environment=project.test_environment(), ) # Save results to internal composition dir diff --git a/src/pact/project.py b/src/pact/project.py index a24ab2d..50dea29 100644 --- a/src/pact/project.py +++ b/src/pact/project.py @@ -67,7 +67,13 @@ if TYPE_CHECKING: from pact.schemas import ArtifactMetadata -from pact.config import ProjectConfig, load_project_config +from pact.config import ( + EnvironmentSpec, + ProjectConfig, + load_global_config, + load_project_config, + resolve_environment, +) from pact.schemas import ( CertificationArtifact, ComponentContract, @@ -407,6 +413,10 @@ def load_sops(self) -> str: def load_config(self) -> ProjectConfig: return load_project_config(self.project_dir) + def test_environment(self) -> EnvironmentSpec: + """Environment for contract test runs, from project then global config.""" + return resolve_environment(self.load_config(), load_global_config()) + # ── Cross-process file locking ───────────────────────────────── # # state.json and audit.jsonl are touched by every CLI invocation and diff --git a/src/pact/scheduler.py b/src/pact/scheduler.py index 8138d7a..1a64639 100644 --- a/src/pact/scheduler.py +++ b/src/pact/scheduler.py @@ -33,6 +33,7 @@ ProjectConfig, resolve_backend, resolve_build_mode, + resolve_environment, resolve_model, resolve_parallel_config, ) @@ -1450,6 +1451,9 @@ async def _phase_polish(self, state: RunState) -> RunState: test_file, src_dir, extra_paths=extra_paths, language=language, project_dir=self.project.project_dir, + environment=resolve_environment( + self.project_config, self.global_config, + ), ) if not results.all_passed: regression_failures.append( @@ -1642,6 +1646,9 @@ async def _run_goodhart_tests( test_file, src_dir, extra_paths=extra_paths, language=language, project_dir=self.project.project_dir, + environment=resolve_environment( + self.project_config, self.global_config, + ), ) if not results.all_passed: goodhart_failures[cid] = results diff --git a/src/pact/test_harness.py b/src/pact/test_harness.py index 19d46c2..0f041d5 100644 --- a/src/pact/test_harness.py +++ b/src/pact/test_harness.py @@ -15,6 +15,7 @@ import logging import os import re +import sys from enum import StrEnum from pathlib import Path from typing import TYPE_CHECKING @@ -192,11 +193,6 @@ async def run_contract_tests( parts = [str(impl_dir), str(impl_dir.parent)] if extra_paths: parts.extend(str(p) for p in extra_paths) - # Include pact's own site-packages so anyio and other pact deps are available - import sysconfig as _sysconfig - _pact_site = _sysconfig.get_path("purelib") - if _pact_site and _pact_site not in parts: - parts.append(_pact_site) env_path = ":".join(parts) if environment: @@ -208,8 +204,9 @@ async def run_contract_tests( "PATH": os.environ.get("PATH", "/usr/bin:/usr/local/bin"), } + python = environment.python_path if environment else sys.executable cmd = [ - "python3", "-m", "pytest", + python, "-m", "pytest", str(test_file), "-v", "--tb=short", "--no-header", f"--rootdir={impl_dir.parent}", @@ -222,6 +219,14 @@ async def run_contract_tests( except TestSubprocessError as e: return _error_results(e.test_id, e.message) + # Otherwise this parses as zero tests with zero errors. + if "No module named pytest" in stderr_text: + return _error_results( + "environment", + f"pytest is not installed for {python}; install it there or set " + "environment.python_path to an interpreter that has it", + ) + return parse_pytest_output(stdout_text, stderr_text) diff --git a/tests/test_certification.py b/tests/test_certification.py index 1b33cdd..1418d10 100644 --- a/tests/test_certification.py +++ b/tests/test_certification.py @@ -144,3 +144,19 @@ def test_verify_artifact_hashes_reports_missing_emission_sentinel(tmp_path): assert verify_artifact_hashes(cert, project) == [ "tests/comp_a/emission_test: missing at certification time" ] + + +def test_certify_runs_tests_under_configured_interpreter(tmp_path): + project = _project_with_contract(tmp_path) + project.save_emission_test("comp_a", "def test_emission():\n pass\n") + project.config_path.write_text( + "environment:\n python_path: /opt/venv/bin/python\n" + ) + runner = AsyncMock(return_value=_passing_results()) + + with patch("pact.certification.run_contract_tests", runner): + asyncio.run(certify(project)) + + assert runner.await_count == 3 + for call in runner.await_args_list: + assert call.kwargs["environment"].python_path == "/opt/venv/bin/python" diff --git a/tests/test_environment.py b/tests/test_environment.py index 49087f2..3ed52c3 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1,5 +1,6 @@ """Tests for EnvironmentSpec.""" import os +import sys from pact.config import EnvironmentSpec, resolve_environment, GlobalConfig, ProjectConfig @@ -7,7 +8,7 @@ class TestEnvironmentSpec: def test_default_inherits_path(self): spec = EnvironmentSpec() assert spec.inherit_path is True - assert spec.python_path == "python3" + assert spec.python_path == sys.executable assert spec.required_tools == ["pytest"] def test_build_env_inherits_parent_path(self): @@ -69,7 +70,12 @@ class TestResolveEnvironment: def test_default_when_no_config(self): spec = resolve_environment(ProjectConfig(), GlobalConfig()) assert spec.inherit_path is True - assert spec.python_path == "python3" + assert spec.python_path == sys.executable + + def test_config_without_python_path_uses_running_interpreter(self): + gc = GlobalConfig(environment={"inherit_path": False}) + spec = resolve_environment(ProjectConfig(), gc) + assert spec.python_path == sys.executable def test_global_config(self): gc = GlobalConfig(environment={ @@ -85,3 +91,18 @@ def test_project_overrides_global(self): pc = ProjectConfig(environment={"python_path": "python3.13"}) spec = resolve_environment(pc, gc) assert spec.python_path == "python3.13" + + +class TestProjectTestEnvironment: + def test_reads_python_path_from_pact_yaml(self, tmp_path): + from pact.project import ProjectManager + project = ProjectManager(tmp_path) + project.config_path.write_text( + "environment:\n python_path: /opt/venv/bin/python\n" + ) + assert project.test_environment().python_path == "/opt/venv/bin/python" + + def test_defaults_to_running_interpreter(self, tmp_path): + from pact.project import ProjectManager + project = ProjectManager(tmp_path) + assert project.test_environment().python_path == sys.executable diff --git a/tests/test_test_harness.py b/tests/test_test_harness.py index 8b04630..9f0968e 100644 --- a/tests/test_test_harness.py +++ b/tests/test_test_harness.py @@ -3,9 +3,12 @@ from __future__ import annotations import asyncio +import sys +import sysconfig from pathlib import Path from unittest.mock import AsyncMock, patch +from pact.config import EnvironmentSpec from pact.test_harness import EvalTier, parse_pytest_output, run_contract_tests, select_test_files @@ -122,9 +125,57 @@ async def mock_exec(*args, **kwargs): asyncio.run(run_contract_tests(test_file, impl_dir)) pythonpath = captured_env.get("PYTHONPATH", "") - assert str(impl_dir) in pythonpath - # impl_dir + parent + pact site-packages (added so anyio is available) - assert len(pythonpath.split(":")) >= 2 + assert pythonpath.split(":") == [str(impl_dir), str(tmp_path)] + + +def _run_capturing(tmp_path, *, environment=None, stderr=b""): + """Run run_contract_tests with a mocked subprocess; return (cmd, env, results).""" + test_file = tmp_path / "test_example.py" + test_file.write_text("def test_pass(): pass") + impl_dir = tmp_path / "impl" + impl_dir.mkdir() + captured = {} + + async def mock_exec(*args, **kwargs): + captured["cmd"] = list(args) + captured["env"] = kwargs.get("env", {}) + proc = AsyncMock() + stdout = b"" if stderr else b"test_x PASSED\n1 passed" + proc.communicate = AsyncMock(return_value=(stdout, stderr)) + proc.returncode = 1 if stderr else 0 + return proc + + with patch("pact.test_harness.asyncio.create_subprocess_exec", side_effect=mock_exec): + results = asyncio.run(run_contract_tests(test_file, impl_dir, environment=environment)) + return captured["cmd"], captured["env"], results + + +class TestInterpreter: + """The harness runs pytest under pact's interpreter or the configured one.""" + + def test_defaults_to_running_interpreter(self, tmp_path): + cmd, _, _ = _run_capturing(tmp_path) + assert cmd[:3] == [sys.executable, "-m", "pytest"] + + def test_uses_environment_python_path(self, tmp_path): + env_spec = EnvironmentSpec(python_path="/opt/py/bin/python3.12") + cmd, _, _ = _run_capturing(tmp_path, environment=env_spec) + assert cmd[0] == "/opt/py/bin/python3.12" + + def test_does_not_inject_pact_site_packages(self, tmp_path): + env_spec = EnvironmentSpec(python_path="/opt/py/bin/python3.12") + _, env, _ = _run_capturing(tmp_path, environment=env_spec) + assert sysconfig.get_path("purelib") not in env["PYTHONPATH"].split(":") + + def test_missing_pytest_is_an_error(self, tmp_path): + env_spec = EnvironmentSpec(python_path="/opt/py/bin/python3.12") + _, _, results = _run_capturing( + tmp_path, environment=env_spec, + stderr=b"/opt/py/bin/python3.12: No module named pytest\n", + ) + assert results.errors == 1 + assert results.failure_details[0].test_id == "environment" + assert "/opt/py/bin/python3.12" in results.failure_details[0].error_message class TestEvalTier: