Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [

dependencies = [
"pydantic>=2.0",
"pytest>=8.2,<10",
"pyyaml>=6.0",
]

Expand Down Expand Up @@ -67,7 +68,6 @@ transmogrifier = [
"transmogrifier>=0.2.0",
]
dev = [
"pytest>=8.2,<10",
"pytest-asyncio>=1.3,<2",
]

Expand Down
3 changes: 3 additions & 0 deletions src/pact/certification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/pact/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import logging
import os
import sys
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"]),
Expand Down
4 changes: 4 additions & 0 deletions src/pact/implementer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/pact/integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/pact/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/pact/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
ProjectConfig,
resolve_backend,
resolve_build_mode,
resolve_environment,
resolve_model,
resolve_parallel_config,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions src/pact/test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Comment thread
MrJoy marked this conversation as resolved.
cmd = [
"python3", "-m", "pytest",
python, "-m", "pytest",
str(test_file),
"-v", "--tb=short", "--no-header",
f"--rootdir={impl_dir.parent}",
Expand All @@ -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)


Expand Down
16 changes: 16 additions & 0 deletions tests/test_certification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
25 changes: 23 additions & 2 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Tests for EnvironmentSpec."""
import os
import sys
from pact.config import EnvironmentSpec, resolve_environment, GlobalConfig, ProjectConfig


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):
Expand Down Expand Up @@ -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={
Expand All @@ -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
57 changes: 54 additions & 3 deletions tests/test_test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
Loading