diff --git a/pyproject.toml b/pyproject.toml index 224d19b..e0aab4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dev = [ "pytest>=8.3,<10", "pytest-asyncio>=0.25,<2", "ruff>=0.11,<1", + "types-PyYAML>=6.0.12.20250516", ] [project.scripts] diff --git a/src/reproforge/cli/operations.py b/src/reproforge/cli/operations.py index 698219f..daec871 100644 --- a/src/reproforge/cli/operations.py +++ b/src/reproforge/cli/operations.py @@ -154,11 +154,7 @@ async def _execute_and_archive( ) -> ReproductionReport: report = await ReproductionEngine(config).run(request, workspace=workspace) selected_environment = config.selected_environment() - secret_values = tuple( - value - for key, value in selected_environment.items() - if key in config.security.allowed_secrets - ) + secret_values = tuple(value for key, value in selected_environment.items() if key in config.security.allowed_secrets) archive = archive_run(workspace, report, secret_values=secret_values) store().upsert(report.run_id, archive, report.status.value) print_report(report) diff --git a/src/reproforge/core/models.py b/src/reproforge/core/models.py index b79f8c5..93dde11 100644 --- a/src/reproforge/core/models.py +++ b/src/reproforge/core/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator -REPORT_SCHEMA_VERSION = "1.0" +REPORT_SCHEMA_VERSION: Literal["1.0"] = "1.0" class StrictModel(BaseModel): diff --git a/src/reproforge/github/client.py b/src/reproforge/github/client.py index 506ca47..4a2269c 100644 --- a/src/reproforge/github/client.py +++ b/src/reproforge/github/client.py @@ -42,11 +42,13 @@ def parse_issue_url(url: str) -> IssueRef: match = _ISSUE_RE.match(url.strip()) if not match: raise ValueError("expected a GitHub issue URL like https://github.com/org/repo/issues/123") - return IssueRef( - owner=match.group("owner"), - repo=match.group("repo"), - number=int(match.group("number")), - url=url, + return IssueRef.model_validate( + { + "owner": match.group("owner"), + "repo": match.group("repo"), + "number": int(match.group("number")), + "url": url, + } ) async def fetch_issue(self, url: str) -> IssueMetadata: @@ -63,13 +65,11 @@ async def fetch_issue(self, url: str) -> IssueMetadata: ) if issue_response.status_code != 200: raise GitHubError( - f"GitHub issue lookup failed with HTTP {issue_response.status_code}: " - f"{_safe_message(issue_response)}" + f"GitHub issue lookup failed with HTTP {issue_response.status_code}: {_safe_message(issue_response)}" ) if repo_response.status_code != 200: raise GitHubError( - f"GitHub repository lookup failed with HTTP {repo_response.status_code}: " - f"{_safe_message(repo_response)}" + f"GitHub repository lookup failed with HTTP {repo_response.status_code}: {_safe_message(repo_response)}" ) issue = issue_response.json() repo = repo_response.json() diff --git a/src/reproforge/harnesses/base.py b/src/reproforge/harnesses/base.py index ff3a179..b79265b 100644 --- a/src/reproforge/harnesses/base.py +++ b/src/reproforge/harnesses/base.py @@ -77,7 +77,7 @@ class HarnessInvocation(StrictModel): class HarnessRuntime(Protocol): """Execution boundary supplied by ReproForge's sandbox layer.""" - async def stream( + def stream( self, invocation: HarnessInvocation, *, @@ -96,7 +96,7 @@ async def prepare(self, request: HarnessRunRequest) -> None: ... def build_invocation(self, request: HarnessRunRequest) -> HarnessInvocation: ... - async def run(self, request: HarnessRunRequest) -> AsyncIterator[HarnessEvent]: ... + def run(self, request: HarnessRunRequest) -> AsyncIterator[HarnessEvent]: ... async def cancel(self) -> None: ... @@ -189,8 +189,7 @@ async def prepare(self, request: HarnessRunRequest) -> None: def build_invocation(self, request: HarnessRunRequest) -> HarnessInvocation: del request raise RuntimeError( - f"{self.id} has no current supported headless launcher; " - "historical artifacts must be imported explicitly" + f"{self.id} has no current supported headless launcher; historical artifacts must be imported explicitly" ) async def run(self, request: HarnessRunRequest) -> AsyncIterator[HarnessEvent]: diff --git a/src/reproforge/reports/github.py b/src/reproforge/reports/github.py index 4cdba7e..74da74f 100644 --- a/src/reproforge/reports/github.py +++ b/src/reproforge/reports/github.py @@ -19,8 +19,7 @@ def render_issue_comment(report: ReproductionReport) -> str: [ "", "**Observed failing command**", - f"`{' '.join(failing.command.argv)}` — " - f"{'timed out' if failing.timed_out else f'exit {failing.exit_code}'}", + f"`{' '.join(failing.command.argv)}` — {'timed out' if failing.timed_out else f'exit {failing.exit_code}'}", ] ) diff --git a/src/reproforge/reports/writer.py b/src/reproforge/reports/writer.py index 8fcda9b..1d3f617 100644 --- a/src/reproforge/reports/writer.py +++ b/src/reproforge/reports/writer.py @@ -135,10 +135,7 @@ def _write_commands( report: ReproductionReport, secret_values: tuple[str, ...], ) -> None: - lines = [ - _jsonl_command(command, metadata, secret_values) - for metadata, command in _iter_commands(report) - ] + lines = [_jsonl_command(command, metadata, secret_values) for metadata, command in _iter_commands(report)] path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") @@ -170,11 +167,7 @@ def _write_reproduction( attempts = report.attempts fallback = attempts[0] if attempts else None source_attempt = next((attempt for attempt in attempts if attempt.reproduced), fallback) - commands = ( - [] - if source_attempt is None - else [result.command.model_dump(mode="json") for result in source_attempt.commands] - ) + commands = [] if source_attempt is None else [result.command.model_dump(mode="json") for result in source_attempt.commands] _write_json(directory / "commands.json", commands, secret_values) if report.minimized_reproduction: (directory / "minimized.txt").write_text( diff --git a/src/reproforge/reproduction/engine.py b/src/reproforge/reproduction/engine.py index 098b15f..bb6793e 100644 --- a/src/reproforge/reproduction/engine.py +++ b/src/reproforge/reproduction/engine.py @@ -67,11 +67,7 @@ async def run( revision = await safe_revision(repository) environment = environment_snapshot(self.config, inspection, revision) selected_environment = self.config.selected_environment() - secret_values = tuple( - value - for key, value in selected_environment.items() - if key in self.config.security.allowed_secrets - ) + secret_values = tuple(value for key, value in selected_environment.items() if key in self.config.security.allowed_secrets) policy_error = runtime_policy_error(self.config) if policy_error is not None: @@ -142,10 +138,8 @@ async def run( started=started, environment=environment, status=RunStatus.SETUP_FAILURE, - summary=( - "Baseline build/check failed before reproduction investigation: " - + " ".join(failed_baseline.command.argv) - ), + summary="Baseline build/check failed before reproduction investigation: " + + " ".join(failed_baseline.command.argv), setup_results=setup_results, baseline_results=baseline_results, caveats=[ @@ -267,11 +261,7 @@ async def _attempt( commands: list[CommandSpec], ) -> AttemptRecord: results = await run_commands(session, commands) - signals = [ - signal - for index, result in enumerate(results) - for signal in inspect_command(result, command_index=index) - ] + signals = [signal for index, result in enumerate(results) for signal in inspect_command(result, command_index=index)] return AttemptRecord(attempt=number, commands=results, signals=signals, reproduced=bool(signals)) async def _maybe_minimize( @@ -304,11 +294,7 @@ async def _minimize_commands( async def preserves(candidate: Sequence[CommandSpec]) -> bool: await self._reset_trial(session, snapshot) results = await run_commands(session, list(candidate)) - signals = [ - signal - for index, result in enumerate(results) - for signal in inspect_command(result, command_index=index) - ] + signals = [signal for index, result in enumerate(results) for signal in inspect_command(result, command_index=index)] return any(signal.fingerprint == fingerprint for signal in signals) return await ddmin(commands, preserves, max_trials=self.config.minimization.max_trials) diff --git a/src/reproforge/reproduction/investigation.py b/src/reproforge/reproduction/investigation.py index 999e9a4..1edb4aa 100644 --- a/src/reproforge/reproduction/investigation.py +++ b/src/reproforge/reproduction/investigation.py @@ -71,9 +71,7 @@ async def investigate_harness( plan = read_harness_plan(plan_path) if plan is None: - caveats.append( - "Harness did not emit .reproforge/harness-plan.json; repository-detected test commands were used." - ) + caveats.append("Harness did not emit .reproforge/harness-plan.json; repository-detected test commands were used.") plan = HarnessPlan() interpretation = plan.interpretation or ("\n".join(messages[-5:]) if messages else None) return plan, interpretation, caveats, runtime.results diff --git a/src/reproforge/reproduction/support.py b/src/reproforge/reproduction/support.py index 6bbbd2c..69bb2c3 100644 --- a/src/reproforge/reproduction/support.py +++ b/src/reproforge/reproduction/support.py @@ -116,10 +116,7 @@ def workspace_policy_error(config: ReproForgeConfig, workspace: Path) -> str | N for raw_path in forbidden_paths: forbidden = Path(raw_path).expanduser().resolve(strict=False) if _paths_intersect(resolved_workspace, forbidden): - return ( - f"Workspace {resolved_workspace} intersects forbidden host path {forbidden}; " - "refusing to inspect or mount it." - ) + return f"Workspace {resolved_workspace} intersects forbidden host path {forbidden}; refusing to inspect or mount it." return None diff --git a/src/reproforge/sandbox/docker.py b/src/reproforge/sandbox/docker.py index c1e1714..e6bbeb2 100644 --- a/src/reproforge/sandbox/docker.py +++ b/src/reproforge/sandbox/docker.py @@ -15,8 +15,6 @@ class DockerSession: - """Long-lived disposable container for one reproduction run.""" - def __init__( self, backend: DockerSandbox, @@ -295,4 +293,8 @@ def _host_user() -> str | None: if os.name == "nt": return None - return f"{os.getuid()}:{os.getgid()}" + getuid = getattr(os, "getuid", None) + getgid = getattr(os, "getgid", None) + if getuid is None or getgid is None: + return None + return f"{getuid()}:{getgid()}"