diff --git a/SECURITY.md b/SECURITY.md index efa8845..b05babf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -140,7 +140,7 @@ Review new alerts on their own data flow. ### Benchmark dependency maintenance -As of September 7, 2026, `paste` 1.0.15 enters through the development dependency +As of October 1, 2026, `paste` 1.0.15 enters through the development dependency `faer` 0.24.4, via `gemm` 0.19.0 and `pulp` 0.22.3. Those are the latest published upstream versions checked on that date. Repository-owned Rust code already uses `pastey`; changing that direct dependency cannot replace upstream @@ -152,7 +152,14 @@ maintenance concern, separate from the logging false positives. `paste` is absen from the library's normal and build dependency graph, including with `exact` enabled, but its procedural macro executes when building development targets. +The root `osv-scanner.toml` temporarily accepts only `RUSTSEC-2024-0436` until +January 1, 2027, when OSV resumes blocking on it. This is a documented acceptance +of the maintenance risk, not a patched dependency. Native OSV configuration +discovery applies it to the root lockfiles scanned by `just security`; `paste` +remains in the package inventory, and all other advisories remain enabled. + Keep this advisory visible in `cargo audit`. Recheck the dependency path with -`cargo tree --locked --all-features -i paste` when updating `faer`, `gemm`, or -`pulp`, and remove the dependency through a maintained upstream release when -available. +`cargo tree --locked --workspace --all-features -i paste` when updating `faer`, +`gemm`, or `pulp`. Remove both the dependency and its OSV exception through a +maintained upstream release when available; reassess explicitly before extending +the exception. diff --git a/docs/code_organization.md b/docs/code_organization.md index 809c591..ec1f991 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -119,6 +119,10 @@ with local stubs; the [contributor review workflow](../CONTRIBUTING.md#coderabbi owns prerequisites and invocation policy. The [justfile](../justfile) owns executable development workflows. +`osv-scanner.toml` owns temporary advisory-specific dependency exceptions, and +`.gitleaks.toml` owns narrow secret-scan false-positive exceptions. Their rationale +and review policy belong in [Security Checks](../SECURITY.md#security-checks). + The shared package also owns managed tool installation, verification, and update implementation. `.python-version`, `rust-toolchain.toml`, and `pyproject.toml` own consumer declarations; `scripts/tests/test_toolchain_integration.py` checks @@ -138,7 +142,18 @@ byte transport, CPU metadata, diagnostics, and zizmor authentication. The thin `scripts/benchmark_process.py` adapter retains benchmark phase signatures and consumer root selection. Generic `subprocess_utils.py` and `run_zizmor.sh` implementations and their duplicate tests are retired; scientific schemas and -benchmark policy remain here. Hosted Dependabot approvals use the pinned shared +benchmark policy remain here. + +Performance consumers use shared Criterion parsing and estimate/comparison +validation, digest verification, archive extraction, byte-preserving document +sections, and multi-file transactions. Local rendering produces complete candidate +outputs before publication. Historical artifact schemas and fingerprint framing, +benchmark selection and eligibility, common-harness orchestration, and complete +run retention remain in the consumer pending the corresponding shared workflow +contract. Generic parsing, staging, and rollback tests belong upstream; local +tests verify the scientific and retained-artifact integration boundaries. + +Hosted Dependabot approvals use the pinned shared GitHub workflow; the [rollout guide](dev/MANAGING_CHANGES.md#dependabot-approval-rollout) owns settings and deployment verification. diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..f618d0b --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,6 @@ +# Advisory-specific, temporary acceptance; keep every other advisory enabled. +# Native OSV configuration applies to the lockfiles in this directory. +[[IgnoredVulns]] +id = "RUSTSEC-2024-0436" +ignoreUntil = 2027-01-01 +reason = "Benchmark-only faer -> gemm/pulp -> paste has no maintained published replacement yet; see SECURITY.md#benchmark-dependency-maintenance." diff --git a/pyproject.toml b/pyproject.toml index 6a4b228..ebc5a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,8 +77,8 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "**/tests/test_*.py" = [ "S101", "SLF001", "D101", "D102", "D103" ] # Static-analysis fixtures intentionally contain the patterns these rules reject. -"tests/semgrep/scripts/python_portability.py" = [ "D103", "INP001", "N813", "PLW1510", "S603", "S607", "SIM115", "UP020", "UP021" ] -"tests/semgrep/scripts/tests/python_exceptions.py" = [ "BLE001", "D100", "D103", "EM101", "INP001", "S110", "S607", "SIM105", "TRY002" ] +"tests/semgrep/scripts/python_portability.py" = [ "D103", "INP001", "N813", "PLW1510", "S603", "S607", "SIM115", "UP020" ] +"tests/semgrep/scripts/tests/python_exceptions.py" = [ "BLE001", "D100", "D103", "EM101", "INP001", "S110", "S607", "SIM105" ] [tool.ruff.lint.mccabe] max-complexity = 10 @@ -139,6 +139,8 @@ line-length = 160 [tool.uv] package = true required-version = "==0.12.21" +# Semgrep wheels bundle the native engine; building its sdist does not. +no-build-package = [ "semgrep" ] # Semgrep 1.178.0 restricts PyJWT to 2.13.x; 2.15.1 contains the OSV fixes. # Keep crypto support while overriding that restriction until upstream updates it. override-dependencies = [ "pyjwt[crypto]>=2.15.1,<3" ] @@ -149,7 +151,8 @@ dev = [ { include-group = "tooling" }, "actionlint-py==1.7.12.25", "pytest==9.1.1", - "ruff==0.16.9", + "ruff==0.16.10", + # 1.179.0 has no Windows wheel; its sdist omits semgrep-core.exe. "semgrep==1.178.0", "shellcheck-py==0.11.0.1", "shfmt-py==4.2.0", diff --git a/scripts/README.md b/scripts/README.md index 5287910..48cd998 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -352,9 +352,10 @@ checks the actual release selectors, preserved scientific evidence, DOI policy, and recipe forwarding. `tests/test_cargo_update_integration.py` executes native Cargo upgrades against a disposable local registry; Python updates have a matching real-uv fixture in the toolchain tests. Common parser, transaction, -Markdown, and fixture regressions belong to the shared package. Scientific, -benchmark, and performance tooling remains consumer-owned. Notebook tooling -remains outside scope. +Markdown, and fixture regressions belong to the shared package. Scientific +eligibility, benchmark orchestration, retained schemas, and report layouts remain +consumer-owned. Shared performance primitives are adopted below. +Notebook tooling remains outside scope. The same pinned release owns opt-in CodeRabbit review orchestration through `research-repo-tools review branch --base=REF` and `review uncommitted`. @@ -400,9 +401,30 @@ Shared process discovery, execution, byte transport, CPU detection, diagnostics, and zizmor authentication belong to research-repo-tools. The former `subprocess_utils.py` and `run_zizmor.sh` implementations and their duplicated unit tests are removed. Consumer tests retain native adapter checks, benchmark -contracts, caller file-policy coverage, and recipe forwarding. The remaining -performance modules own la-stack's retained schemas, scientific eligibility, -benchmark inventories, and report layouts; shared primitives are not a drop-in -replacement for those contracts. +contracts, caller file-policy coverage, and recipe forwarding. + +Performance scripts also use the published shared Criterion parser and estimate +validation, comparison arithmetic, exact-byte digest verification, safe archive +extraction, document marker replacement, and multi-file publication transaction. +Raw Criterion numeric strings are rejected by the shared parser. Plotting and +release publication still require complete confidence intervals; hosted baselines +and full local summaries additionally require 100 samples and 95% intervals. +README marker replacement preserves bytes outside the selected section. +Publication candidates are fully rendered and validated before one transaction +replaces the report, evidence, archive index, and retained summaries. Shared +recovery errors identify preserved backups if rollback fails. + +The remaining modules own la-stack's historical CSV/JSON schemas and fingerprints, +scientific eligibility, benchmark inventories, current-harness installation, +release selection policy, complete run retention, and multi-library plot layout. +Existing evidence keeps its schema and digest framing. Generic parser and +transaction regressions belong upstream; consumer tests check retained-schema +round trips, complete output groups, failure preservation, and scientific policy. +The remaining workflow migration requires a published shared contract for common +harnesses, configurable measurement completeness, and immutable per-run retention. +[research-repo-tools#64](https://github.com/acgetchell/research-repo-tools/issues/64) +tracks that contract; +[la-stack#268](https://github.com/acgetchell/la-stack/issues/268) tracks adoption +after publication. See `docs/RELEASING.md` for the full release workflow. diff --git a/scripts/archive_performance.py b/scripts/archive_performance.py index d1f5f32..db2357b 100644 --- a/scripts/archive_performance.py +++ b/scripts/archive_performance.py @@ -26,16 +26,17 @@ import shutil import subprocess import sys -import tarfile import tempfile import tomllib from collections.abc import Iterator, Mapping -from contextlib import ExitStack, contextmanager +from contextlib import contextmanager from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, cast +from research_repo_tools.archives import extract_archive +from research_repo_tools.files import replace_many from research_repo_tools.process import ExecutableNotFoundError, cpu_description, format_exception_diagnostics, run_git_bytes from bench_compare import HOW_TO_UPDATE_SECTION, render_release_artifacts @@ -45,9 +46,9 @@ from performance_artifacts import ( NO_API_COMPATIBILITY, ArtifactPaths, + bundle_outputs, ensure_distinct_paths, load_bundle, - publish_bundle, resolve_shared_harness_compatibility, ) @@ -190,9 +191,11 @@ class ArchivePaths: @dataclass(frozen=True) class GeneratedReport: - """A generated benchmark report that remains valid after worktree cleanup.""" + """Validated report and candidate outputs available until temporary cleanup.""" text: str + artifacts: ArtifactPaths + outputs: Mapping[Path, bytes] @dataclass(frozen=True) @@ -434,50 +437,8 @@ def _normalize_how_to_update(text: str) -> str: return f"{text.rstrip()}\n\n{HOW_TO_UPDATE_SECTION}" -def _replace_file(src: Path, dst: Path) -> None: - src.replace(dst) - - def _write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with ExitStack() as cleanup: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as tmp: - tmp_path = Path(tmp.name) - cleanup.callback(tmp_path.unlink, missing_ok=True) - tmp.write(text) - tmp.flush() - os.fsync(tmp.fileno()) - _replace_file(tmp_path, path) - - -def _restore_file(path: Path, payload: bytes | None) -> None: - """Restore one file snapshot without relying on the publication writer.""" - if payload is None: - path.unlink(missing_ok=True) - return - path.parent.mkdir(parents=True, exist_ok=True) - with ExitStack() as cleanup: - with tempfile.NamedTemporaryFile( - "wb", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".restore", - delete=False, - ) as tmp: - restore_path = Path(tmp.name) - cleanup.callback(restore_path.unlink, missing_ok=True) - tmp.write(payload) - tmp.flush() - os.fsync(tmp.fileno()) - restore_path.replace(path) + replace_many({path: text.encode("utf-8")}) def _snapshot_regular_file(path: Path, *, label: str) -> bytes | None: @@ -498,35 +459,10 @@ def _decode_text_snapshot(payload: bytes) -> str: return payload.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") -def _restore_snapshots(snapshots: tuple[tuple[Path, bytes | None], ...]) -> tuple[BaseException, ...]: - """Attempt every file restoration and return all rollback failures.""" - errors: list[BaseException] = [] - for path, payload in reversed(snapshots): - try: - _restore_file(path, payload) - except BaseException as exc: # noqa: BLE001 - errors.append(exc) - return tuple(errors) - - -@contextmanager -def _publish_text_outputs(outputs: Mapping[Path, str]) -> Iterator[None]: - """Retain complete scratch summaries with the report's rollback boundary.""" - snapshots = tuple((path, _snapshot_regular_file(path, label="benchmark summary")) for path in outputs) - try: - for path, text in outputs.items(): - _write_text(path, text) - yield - except BaseException as error: - failures = _restore_snapshots(snapshots) - if failures: - msg = "benchmark summary publication and rollback failed" - raise BaseExceptionGroup(msg, [error, *failures]) from None - raise - - -def _archive_readme(archive_dir: Path) -> str: - reports = sorted(path.name for path in archive_dir.glob("*.md") if path.name != "README.md") +def _archive_readme(archive_dir: Path, planned: Path | None = None) -> str: + reports = {path.name for path in archive_dir.glob("*.md") if path.name != "README.md"} + if planned is not None: + reports.add(planned.name) lines = [ "# Archived Performance Reports", "", @@ -535,7 +471,7 @@ def _archive_readme(archive_dir: Path) -> str: "", ] if reports: - lines.extend(f"- [{name.removesuffix('.md')}]({name})" for name in reports) + lines.extend(f"- [{name.removesuffix('.md')}]({name})" for name in sorted(reports)) else: lines.append("- No archived performance reports yet.") if (archive_dir / "studies").is_dir(): @@ -543,11 +479,6 @@ def _archive_readme(archive_dir: Path) -> str: return "\n".join(lines) + "\n" -def update_archive_index(archive_dir: Path) -> None: - """Write a lexicographically sorted archive index.""" - _write_text(archive_dir / "README.md", _archive_readme(archive_dir)) - - def _format_command_failure(command: list[str], exc: subprocess.CalledProcessError) -> str: header = f"command failed ({exc.returncode}): {' '.join(command)}" details = format_exception_diagnostics(exc).partition("\n")[2] @@ -989,18 +920,6 @@ def _comparison_benchmark_env(checkout: Path, *, api_compatibility: str | None = return env -def _safe_extract_tar(archive: Path, target_dir: Path) -> None: - target_dir.mkdir(parents=True, exist_ok=True) - target_root = target_dir.resolve() - with tarfile.open(archive, "r:gz") as tar: - for member in tar.getmembers(): - member_path = (target_dir / member.name).resolve() - if not member_path.is_relative_to(target_root): - msg = f"refusing to extract unsafe archive member {member.name!r}" - raise ValueError(msg) - tar.extractall(target_dir, filter="data") - - def _download_release_baseline(*, baseline_tag: str, download_dir: Path, repo_root: Path) -> Path: artifact = download_dir / f"la-stack-{baseline_tag}-criterion-baseline.tar.gz" _run_tool( @@ -1306,8 +1225,14 @@ def _prepare_github_release_assets(*, current_tag: str, baseline_tag: str, repo_ repo_root=repo_root, ) target_dir = target_worktree / "target" - _safe_extract_tar(baseline_archive, target_dir) - _safe_extract_tar(current_archive, target_dir) + for phase, archive in (("baseline", baseline_archive), ("current", current_archive)): + extracted = tmp_dir / f"{phase}-extracted" + extract_archive(archive, extracted) + # Only this disposable checkout can observe partial assembly. Keep the + # expanded archive off the heap while retaining shared path validation. + for path in sorted(extracted.rglob("*")): + if path.is_file(): + replace_many({target_dir / path.relative_to(extracted): path.read_bytes()}) # Published artifacts retain their release-specific harnesses. Never let an # embedded or stale local manifest claim that these samples shared one. metadata_path = target_dir / "criterion" / _BENCHMARK_HARNESS_METADATA @@ -1516,20 +1441,21 @@ def _generated_report_in_temp_worktree( baseline_run=baseline_run, ) report_text = _read_text(report) + if render_release_artifacts(temporary_artifacts) != report_text: + msg = "retained performance-comparison artifacts did not reproduce the generated Markdown" + raise ValueError(msg) bundle = load_bundle(temporary_artifacts) summaries = {} if config.baseline_source == "local": temporary_summaries = summary_outputs(worktree / "target/criterion", config.baseline_tag, temporary_artifacts) + replace_many({path: text.encode("utf-8") for path, text in temporary_summaries.items()}) summaries = { published_artifacts.csv.with_suffix(".full.csv" if path.suffix == ".csv" else ".full.provenance.json"): text for path, text in temporary_summaries.items() } - with _publish_text_outputs(summaries), publish_bundle(published_artifacts, bundle): - durable_text = render_release_artifacts(published_artifacts) - if durable_text != report_text: - msg = "durable performance-comparison artifacts did not reproduce the generated Markdown" - raise ValueError(msg) - yield GeneratedReport(text=durable_text) + outputs = bundle_outputs(published_artifacts, bundle) + outputs.update({path: text.encode("utf-8") for path, text in summaries.items()}) + yield GeneratedReport(text=report_text, artifacts=temporary_artifacts, outputs=outputs) def _current_archive_state( @@ -1594,31 +1520,7 @@ def _existing_archive_matches(*, archive_path: Path | None, current_id: ReportId return True -def _promotion_snapshots( - request: PromotionRequest, - *, - index_path: Path, - archive_path: Path | None, - archive_exists: bool, -) -> tuple[tuple[Path, bytes | None], ...]: - """Snapshot every path mutated by a report promotion.""" - snapshots: list[tuple[Path, bytes | None]] = [] - if archive_path is not None and not archive_exists: - snapshots.append((archive_path, None)) - snapshots.extend( - ( - (request.current, _snapshot_regular_file(request.current, label="current report")), - (index_path, _snapshot_regular_file(index_path, label="archive index")), - ) - ) - if request.output is not None: - snapshots.append((request.output, _snapshot_regular_file(request.output, label="rendered output"))) - if request.retained_outputs: - snapshots.extend((path, _snapshot_regular_file(path, label="retained benchmark summary")) for path in request.retained_outputs) - return tuple(snapshots) - - -def _promote_report_text(*, source_text: str, request: PromotionRequest) -> ReportId: +def _promote_report_text(*, source_text: str, request: PromotionRequest, generated_outputs: Mapping[Path, bytes] | None = None) -> ReportId: """Archive the old report and atomically promote validated Markdown text.""" if request.expected.current_tag == request.expected.baseline_tag: msg = "cannot promote a same-version local performance comparison as release documentation" @@ -1645,31 +1547,18 @@ def _promote_report_text(*, source_text: str, request: PromotionRequest) -> Repo current_id=current_id, current_text=current_text, ) - snapshots = _promotion_snapshots( - request, - index_path=index_path, - archive_path=archive_path, - archive_exists=archive_exists, - ) - try: - for path, text in (request.retained_outputs or {}).items(): - if not path.exists() or _read_text(path) != text: - _write_text(path, text) - if archive_path is not None and not archive_exists: - if current_text is None: - msg = "archive promotion invariant violated" - raise AssertionError(msg) - _write_text(archive_path, current_text) - _write_text(request.current, source_text) - update_archive_index(request.archive_dir) - if request.output is not None: - _write_text(request.output, source_text) - except BaseException as promotion_error: - rollback_errors = _restore_snapshots(snapshots) - if rollback_errors: - group_message = "performance report promotion and rollback failed" - raise BaseExceptionGroup(group_message, [promotion_error, *rollback_errors]) from None - raise + outputs = dict(generated_outputs or {}) + outputs.update({path: text.encode("utf-8") for path, text in (request.retained_outputs or {}).items()}) + if archive_path is not None and not archive_exists: + if current_text is None: + msg = "archive promotion invariant violated" + raise AssertionError(msg) + outputs[archive_path] = current_text.encode("utf-8") + outputs[request.current] = source_text.encode("utf-8") + outputs[index_path] = _archive_readme(request.archive_dir, archive_path).encode("utf-8") + if request.output is not None: + outputs[request.output] = source_text.encode("utf-8") + replace_many(outputs) return source_id @@ -1748,8 +1637,9 @@ def generate_and_promote_worktree_report( source_text=generated.text, request=replace( request, - retained_outputs=retained_outputs(current.parent / "performance", published_artifacts) if config.baseline_source == "local" else {}, + retained_outputs=retained_outputs(current.parent / "performance", generated.artifacts) if config.baseline_source == "local" else {}, ), + generated_outputs=generated.outputs, ) @@ -1796,7 +1686,7 @@ def generate_worktree_report( f"expected {expected.current_tag} vs {expected.baseline_tag}" ) raise ValueError(msg) - _write_text(output, report_text) + replace_many({**generated.outputs, output: report_text.encode("utf-8")}) return report_id diff --git a/scripts/bench_compare.py b/scripts/bench_compare.py index 3ad4815..d9d6cd1 100644 --- a/scripts/bench_compare.py +++ b/scripts/bench_compare.py @@ -23,20 +23,18 @@ import argparse import json -import math -import os import re import subprocess import sys -import tempfile import tomllib from collections.abc import Mapping -from contextlib import ExitStack from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Literal, Protocol, cast +from research_repo_tools.criterion import Comparison as TimingComparison, Estimate, read_estimate +from research_repo_tools.files import replace_many from research_repo_tools.process import ExecutableNotFoundError, format_exception_diagnostics from benchmark_process import find_project_root, run_git_command @@ -50,10 +48,10 @@ ReleasePair, ReportSource, TimingEstimate, + bundle_outputs, ensure_distinct_paths, freeze_mapping, load_bundle, - publish_bundle, resolve_shared_harness_compatibility, ) @@ -232,20 +230,7 @@ class CriterionEstimate: def __post_init__(self) -> None: """Keep every stored timing finite, positive, and interval-complete.""" - for field, value in ( - ("point_ns", self.point_ns), - ("ci_lo_ns", self.ci_lo_ns), - ("ci_hi_ns", self.ci_hi_ns), - ): - if value is not None and (not math.isfinite(value) or value <= 0): - msg = f"{field} must be finite and positive: {value!r}" - raise ValueError(msg) - if (self.ci_lo_ns is None) != (self.ci_hi_ns is None): - msg = "Criterion confidence interval must contain both bounds or neither" - raise ValueError(msg) - if self.ci_lo_ns is not None and self.ci_hi_ns is not None and self.ci_lo_ns > self.ci_hi_ns: - msg = f"Criterion confidence interval lower bound exceeds upper bound: {self.ci_lo_ns} > {self.ci_hi_ns}" - raise ValueError(msg) + Estimate(self.point_ns, self.ci_lo_ns, self.ci_hi_ns) @property def has_confidence_interval(self) -> bool: @@ -305,12 +290,12 @@ def current_ns(self) -> float: @property def speedup(self) -> float: """Return baseline/current, where values above one are faster.""" - return self.baseline_ns / self.current_ns + return TimingComparison(self.bench, Estimate(self.baseline_ns), Estimate(self.current_ns)).speedup @property def pct_change(self) -> float: """Return signed point-estimate change, where negative is faster.""" - return ((self.current_ns - self.baseline_ns) / self.baseline_ns) * 100.0 + return 0.0 - TimingComparison(self.bench, Estimate(self.baseline_ns), Estimate(self.current_ns)).percent_reduction @property def baseline_nalgebra_ns(self) -> float | None: @@ -462,61 +447,9 @@ def _is_selected_comparison_row( def _read_estimate(estimates_json: Path, stat: str = "median") -> CriterionEstimate: - """Read and validate a Criterion point estimate and confidence interval.""" - try: - data = json.loads(estimates_json.read_text(encoding="utf-8")) - except json.JSONDecodeError as err: - msg = f"malformed Criterion estimates JSON in {estimates_json}: {err}" - raise ValueError(msg) from err - - if not isinstance(data, dict): - msg = f"expected JSON object in {estimates_json}" - raise TypeError(msg) - - stat_obj = data.get(stat) - if not isinstance(stat_obj, dict): - msg = f"stat '{stat}' not found in {estimates_json}" - raise KeyError(msg) - - point = _read_numeric_field(stat_obj, "point_estimate", estimates_json, stat) - if "confidence_interval" not in stat_obj: - return CriterionEstimate(point_ns=point, ci_lo_ns=None, ci_hi_ns=None) - ci = stat_obj["confidence_interval"] - if not isinstance(ci, dict): - msg = f"field 'confidence_interval' for stat '{stat}' in {estimates_json} is not an object" - raise TypeError(msg) - - lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat) - hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat) - if lo > hi: - msg = f"invalid confidence interval for stat '{stat}' in {estimates_json}: lower_bound {lo} exceeds upper_bound {hi}" - raise ValueError(msg) - return CriterionEstimate(point_ns=point, ci_lo_ns=lo, ci_hi_ns=hi) - - -def _read_numeric_field( - obj: dict[str, object], - field: str, - estimates_json: Path, - stat: str, -) -> float: - """Read a numeric Criterion field with file and statistic context.""" - if field not in obj: - msg = f"field '{field}' for stat '{stat}' not found in {estimates_json}" - raise KeyError(msg) - value = obj[field] - if isinstance(value, bool) or not isinstance(value, int | float | str): - msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" - raise TypeError(msg) - try: - result = float(value) - except (OverflowError, ValueError) as err: - msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" - raise ValueError(msg) from err - if not math.isfinite(result) or result <= 0: - msg = f"field '{field}' for stat '{stat}' in {estimates_json} must be finite and positive: {value!r}" - raise ValueError(msg) - return result + """Adapt shared Criterion parsing to the retained report schema.""" + estimate = read_estimate(estimates_json, statistic=cast("Statistic", stat)) + return CriterionEstimate(estimate.point, estimate.lower, estimate.upper) def _read_harness_provenance( @@ -1906,7 +1839,11 @@ def _release_artifact_bundle( def render_release_artifacts(paths: ArtifactPaths) -> str: """Reload, validate, and render a report without Criterion, Cargo, or Git.""" - bundle = load_bundle(paths) + return render_bundle(load_bundle(paths), paths) + + +def render_bundle(bundle: PerformanceBundle, paths: ArtifactPaths) -> str: + """Render validated retained data before publishing any output.""" context = bundle.context selection = CriterionSelection( suite=cast("BenchmarkSuite", context.suite), @@ -2088,30 +2025,15 @@ def _write_and_render_artifacts( # noqa: PLR0913 settings=settings, collection=collection, ) - with publish_bundle(paths, bundle): - markdown = render_release_artifacts(paths) - _write_text_atomic(output_path, markdown) + outputs = bundle_outputs(paths, bundle) + ensure_distinct_paths({"report": output_path, "artifact CSV": paths.csv, "artifact provenance": paths.provenance}) + outputs[output_path] = render_bundle(bundle, paths).encode("utf-8") + replace_many(outputs) def _write_text_atomic(path: Path, text: str) -> None: - """Replace a UTF-8 text file only after its complete payload is durable.""" - path.parent.mkdir(parents=True, exist_ok=True) - with ExitStack() as cleanup: - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - newline="", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - staged = Path(handle.name) - cleanup.callback(staged.unlink, missing_ok=True) - handle.write(text) - handle.flush() - os.fsync(handle.fileno()) - staged.replace(path) + """Publish a standalone report through the shared transaction.""" + replace_many({path: text.encode("utf-8")}) def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, PLR0915 @@ -2187,7 +2109,11 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, file=sys.stderr, ) return 2 - table = _comparison_tables(collection.comparisons, baseline_name) + try: + table = _comparison_tables(collection.comparisons, baseline_name) + except ValueError as err: + print(f"Invalid Criterion comparison data: {err}", file=sys.stderr) + return 2 else: coverage_errors = _snapshot_coverage_errors( criterion_dir, @@ -2242,8 +2168,8 @@ def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, md = _generate_markdown(root, table, settings) try: _write_text_atomic(output_path, md) - except OSError as err: - print(f"Could not write benchmark report: {err}", file=sys.stderr) + except (ExceptionGroup, OSError, ValueError) as err: + print(f"Could not write benchmark report: {format_exception_diagnostics(err)}", file=sys.stderr) return 2 print(f"📊 Wrote {output_path}") diff --git a/scripts/benchmark_summaries.py b/scripts/benchmark_summaries.py index 77d174b..5b4eb09 100644 --- a/scripts/benchmark_summaries.py +++ b/scripts/benchmark_summaries.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Literal +from research_repo_tools.criterion import read_estimate + from criterion_measurements import positive_number, read_object, validate_measurement from performance_artifacts import ArtifactPaths, TimingEstimate, load_bundle @@ -62,16 +64,11 @@ def report_input_paths(report: ArtifactPaths) -> dict[str, Path]: } -def _estimate(directory: Path, statistic: str) -> TimingEstimate: - estimates = read_object(directory / "estimates.json") - estimate = estimates[statistic] - if not isinstance(estimate, dict) or not isinstance(interval := estimate.get("confidence_interval"), dict): - raise TypeError(f"missing {statistic} confidence interval in {directory}") - return TimingEstimate( - median_ns=positive_number(estimate.get("point_estimate")), - ci_lower_ns=positive_number(interval.get("lower_bound")), - ci_upper_ns=positive_number(interval.get("upper_bound")), - ) +def _estimate(directory: Path, statistic: Literal["mean", "median"]) -> TimingEstimate: + estimate = read_estimate(directory / "estimates.json", statistic=statistic) + if estimate.lower is None or estimate.upper is None: + raise ValueError(f"{directory}: complete summaries require confidence intervals") + return TimingEstimate(estimate.point, estimate.lower, estimate.upper) def collect_measurements(criterion: Path, baseline: str) -> tuple[Measurement, ...]: diff --git a/scripts/criterion_dim_plot.py b/scripts/criterion_dim_plot.py index a0e4430..5c8cfc6 100644 --- a/scripts/criterion_dim_plot.py +++ b/scripts/criterion_dim_plot.py @@ -24,12 +24,14 @@ import tempfile import tomllib from collections.abc import Mapping -from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path -from typing import Final, Protocol, TypeGuard, cast +from typing import Final, TypeGuard, cast -from research_repo_tools.process import ExecutableNotFoundError, cpu_description +from research_repo_tools.criterion import Statistic, read_estimate +from research_repo_tools.files import replace_many +from research_repo_tools.process import ExecutableNotFoundError, cpu_description, format_exception_diagnostics +from research_repo_tools.publication import MarkerPair, replace_section from benchmark_contract import benchmark_contract_digest from benchmark_process import find_project_root, run_git_command, run_safe_command @@ -126,48 +128,10 @@ def __post_init__(self) -> None: _require_confidence_interval(self.fa_lo, self.fa_hi, "faer row") -class ReadmeMarkerError(ValueError): - """Base error for invalid README BENCH_TABLE markers.""" - - -class MarkerNotFoundError(ReadmeMarkerError): - """Raised when README markers are missing or not unique.""" - - -class MarkerOrderError(ReadmeMarkerError): - """Raised when README markers are out of order.""" - - class ReadmeBenchmarkLinkError(ValueError): """Raised when canonical README benchmark artifact links are incomplete.""" -class PublicationRollbackError(RuntimeError): - """Raised when artifact publication fails and rollback is incomplete.""" - - -class _ReadmeArgs(Protocol): - @property - def update_readme(self) -> bool: ... - - @property - def readme(self) -> str: ... - - @property - def metric(self) -> str: ... - - @property - def stat(self) -> str: ... - - @property - def sample(self) -> str: ... - - -class _RenderArgs(Protocol): - @property - def no_plot(self) -> bool: ... - - type ParsedObject = dict[str, object] @@ -338,57 +302,10 @@ def _format_legend_label(name: str, version: str) -> str: def _read_estimate(estimates_json: Path, stat: str) -> tuple[float, float, float]: - data = _read_json_object(estimates_json) - - stat_obj = data.get(stat) - if not _is_parsed_object(stat_obj): - raise KeyError(f"stat '{stat}' not found in {estimates_json}") - - point = _read_numeric_field(stat_obj, "point_estimate", estimates_json, stat) - if "confidence_interval" not in stat_obj: - msg = f"field 'confidence_interval' for stat '{stat}' not found in {estimates_json}" - raise KeyError(msg) - ci = stat_obj["confidence_interval"] - if not _is_parsed_object(ci): - msg = f"field 'confidence_interval' for stat '{stat}' in {estimates_json} is not an object" - raise TypeError(msg) - - lo = _read_numeric_field(ci, "lower_bound", estimates_json, stat) - hi = _read_numeric_field(ci, "upper_bound", estimates_json, stat) - _require_confidence_interval(lo, hi, f"{stat}.confidence_interval in {estimates_json}") - return (point, lo, hi) - - -def _read_json_object(path: Path) -> ParsedObject: - try: - data: object = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as err: - msg = f"malformed Criterion estimates JSON in {path}: {err}" - raise ValueError(msg) from err - return _require_parsed_object(data, str(path)) - - -def _read_numeric_field( - obj: ParsedObject, - field: str, - estimates_json: Path, - stat: str, -) -> float: - if field not in obj: - msg = f"field '{field}' for stat '{stat}' not found in {estimates_json}" - raise KeyError(msg) - - value = obj[field] - if isinstance(value, bool) or not isinstance(value, int | float | str): - msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" - raise TypeError(msg) - - try: - parsed = float(value) - except (OverflowError, ValueError) as err: - msg = f"field '{field}' for stat '{stat}' in {estimates_json} is not numeric: {value!r}" - raise ValueError(msg) from err - return _require_positive_finite_time(parsed, f"{stat}.{field} in {estimates_json}") + estimate = read_estimate(estimates_json, statistic=cast("Statistic", stat)) + if estimate.lower is None or estimate.upper is None: + raise ValueError(f"{estimates_json}: plotted estimates require a complete confidence interval") + return estimate.point, estimate.lower, estimate.upper def _require_positive_finite_time(value: float, context: str) -> float: @@ -440,32 +357,11 @@ def _readme_table_markers(metric: str, stat: str, sample: str) -> tuple[str, str def _update_readme_table(readme_path: Path, marker_begin: str, marker_end: str, table_md: str) -> bool: - lines = readme_path.read_text(encoding="utf-8").splitlines(keepends=True) - - begin_indices = [i for i, line in enumerate(lines) if line.strip() == marker_begin] - end_indices = [i for i, line in enumerate(lines) if line.strip() == marker_end] - - if len(begin_indices) != 1 or len(end_indices) != 1: - msg = f"README markers not found or not unique (begin={len(begin_indices)}, end={len(end_indices)})." - raise MarkerNotFoundError(msg) - - begin_idx = begin_indices[0] - end_idx = end_indices[0] - if begin_idx >= end_idx: - msg = "README markers are out of order." - raise MarkerOrderError(msg) - - table_lines = ["\n", *[line + "\n" for line in table_md.strip("\n").splitlines()], "\n"] - new_lines = [ - *lines[: begin_idx + 1], - *table_lines, - *lines[end_idx:], - ] - - if new_lines == lines: + original = readme_path.read_bytes() + updated = replace_section(original, MarkerPair(marker_begin, marker_end), table_md) + if updated == original: return False - - readme_path.write_text("".join(new_lines), encoding="utf-8", newline="\n") + replace_many({readme_path: updated}) return True @@ -1196,76 +1092,14 @@ def _validate_publication_paths(root: Path, args: PlotCliArgs, *, out_svg: Path, return 0 -def _changed_staged_files(pairs: list[tuple[Path, Path]]) -> list[tuple[Path, Path]]: - """Return staged files whose destination bytes differ or do not exist.""" - changed_pairs: list[tuple[Path, Path]] = [] - for staged, destination in pairs: - if destination.is_file() and staged.read_bytes() == destination.read_bytes(): - continue - changed_pairs.append((staged, destination)) - return changed_pairs - - -def _replace_staged_files(pairs: list[tuple[Path, Path]], backup_dir: Path) -> None: - """Replace a group of publication files and roll back on any failure.""" - changed_pairs = _changed_staged_files(pairs) - backups: dict[Path, Path | None] = {} - for index, (_staged, destination) in enumerate(changed_pairs): - destination.parent.mkdir(parents=True, exist_ok=True) - if destination.is_file(): - backup = backup_dir / f"backup-{index}" - shutil.copy2(destination, backup) - backups[destination] = backup - elif destination.exists(): - msg = f"publication destination is not a regular file: {destination}" - raise ValueError(msg) - else: - backups[destination] = None - - replaced: list[Path] = [] - try: - for staged, destination in changed_pairs: - staged.replace(destination) - replaced.append(destination) - except OSError as primary: - rollback_errors: list[str] = [] - for destination in reversed(replaced): - backup = backups[destination] - try: - if backup is None: - destination.unlink(missing_ok=True) - else: - backup.replace(destination) - except OSError as rollback: - rollback_errors.append(f"could not restore {destination}: {rollback}") - if rollback_errors: - msg = f"artifact replacement failed ({primary}); rollback failed: {'; '.join(rollback_errors)}; backups preserved at {backup_dir}" - raise PublicationRollbackError(msg) from primary - raise - - -def _remove_publication_backup(backup_dir: Path) -> None: - """Clean up an unneeded backup without masking the publication outcome.""" +def _publish_staged_files(pairs: list[tuple[Path, Path]]) -> bool: + """Publish the complete CSV/SVG/provenance/README group through the shared API.""" try: - shutil.rmtree(backup_dir) - except OSError as exc: - print(f"Warning: could not remove artifact backup {backup_dir}: {exc}", file=sys.stderr) - - -def _publish_staged_files(pairs: list[tuple[Path, Path]], root: Path) -> bool: - """Publish staged files together, preserving backups after rollback failure.""" - backup_dir = Path(tempfile.mkdtemp(prefix=".criterion-dim-plot-backup-", dir=root)) - with ExitStack() as cleanup: - cleanup.callback(_remove_publication_backup, backup_dir) - try: - _replace_staged_files(pairs, backup_dir) - except PublicationRollbackError as exc: - cleanup.pop_all() - print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) - return False - except (OSError, ValueError) as exc: - print(f"could not publish benchmark artifacts atomically: {exc}", file=sys.stderr) - return False + outputs = {destination: staged.read_bytes() for staged, destination in pairs} + replace_many({path: payload for path, payload in outputs.items() if path.is_symlink() or not path.is_file() or path.read_bytes() != payload}) + except (OSError, ValueError, ExceptionGroup) as exc: + print(f"could not publish benchmark artifacts: {format_exception_diagnostics(exc)}", file=sys.stderr) + return False return True @@ -1289,12 +1123,12 @@ def _update_staged_readme_publication( msg = f"{root / 'Cargo.toml'} has no string package version" raise ReadmeBenchmarkLinkError(msg) updated_readme = _replace_readme_benchmark_asset_versions( - staged_readme.read_text(encoding="utf-8"), + staged_readme.read_bytes().decode("utf-8"), metric=args.metric, stat=args.stat, version=package_version, ) - staged_readme.write_text(updated_readme, encoding="utf-8", newline="\n") + staged_readme.write_bytes(updated_readme.encode("utf-8")) def _stage_and_publish_outputs( # noqa: PLR0913 @@ -1352,7 +1186,7 @@ def _stage_and_publish_outputs( # noqa: PLR0913 return 2 pairs.append((staged_readme, readme_path)) - if not _publish_staged_files(pairs, root): + if not _publish_staged_files(pairs): return 2 if skipped: @@ -1368,49 +1202,6 @@ def _stage_and_publish_outputs( # noqa: PLR0913 return 0 -def _maybe_update_readme(root: Path, args: _ReadmeArgs, rows: list[Row]) -> int: - if not args.update_readme: - return 0 - - readme_path = _resolve_under_root(root, args.readme) - - marker_begin, marker_end = _readme_table_markers(args.metric, args.stat, args.sample) - table_md = _markdown_table(rows, args.stat) - - try: - changed = _update_readme_table(readme_path, marker_begin, marker_end, table_md) - except (OSError, ValueError) as e: - print(str(e), file=sys.stderr) - return 2 - - if changed: - print(f"Updated README table: {readme_path}") - - return 0 - - -def _maybe_render_plot(args: _RenderArgs, req: PlotRequest, skipped: list[str]) -> int: - if args.no_plot: - print(f"Wrote CSV: {req.csv_path}") - return 0 - - try: - _render_svg_with_gnuplot(req) - except (FileNotFoundError, subprocess.CalledProcessError) as e: - print(str(e), file=sys.stderr) - print(f"Wrote CSV instead: {req.csv_path}", file=sys.stderr) - return 1 - - if skipped: - print("Warning: some dimension groups were skipped:") - for s in skipped: - print(f" - {s}") - - print(f"Wrote CSV: {req.csv_path}") - print(f"Wrote SVG: {req.out_svg}") - return 0 - - def main(argv: list[str] | None = None) -> int: # noqa: C901, PLR0911, PLR0912, PLR0915 """Generate benchmark CSV and optional SVG or README output.""" args = _parse_args(sys.argv[1:] if argv is None else argv) diff --git a/scripts/criterion_measurements.py b/scripts/criterion_measurements.py index 3ebe30f..205cdba 100644 --- a/scripts/criterion_measurements.py +++ b/scripts/criterion_measurements.py @@ -1,10 +1,9 @@ """Validate raw Criterion measurements shared by local and hosted retention.""" import json -import math from typing import TYPE_CHECKING, cast -from performance_artifacts import TimingEstimate +from research_repo_tools.criterion import Estimate, parse_estimate if TYPE_CHECKING: from pathlib import Path @@ -20,9 +19,7 @@ def read_object(path: Path) -> dict[str, object]: def positive_number(value: object) -> float: """Reject booleans, nonnumeric values, and invalid timing numbers.""" - if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value) or value <= 0: - raise ValueError(f"expected a finite positive timing value, got {value!r}") - return float(value) + return Estimate(cast("float", value)).point def validate_measurement(directory: Path) -> None: @@ -32,17 +29,16 @@ def validate_measurement(directory: Path) -> None: values = samples.get(field) if not isinstance(values, list) or len(values) != 100: raise ValueError(f"{directory}: {field} must contain 100 samples") - for value in values: - positive_number(value) - estimates = read_object(directory / "estimates.json") + for index, value in enumerate(values): + try: + positive_number(value) + except ValueError as exc: + raise ValueError(f"{directory / 'sample.json'}: {field}[{index}]: {exc}") from exc + payload = (directory / "estimates.json").read_bytes() for statistic in ("mean", "median"): - estimate = estimates.get(statistic) - if not isinstance(estimate, dict) or not isinstance(interval := estimate.get("confidence_interval"), dict): - raise TypeError(f"{directory}: missing {statistic} estimate or confidence interval") - if interval.get("confidence_level") != 0.95: - raise ValueError(f"{directory}: expected a 95% confidence interval") - TimingEstimate( - median_ns=positive_number(estimate.get("point_estimate")), - ci_lower_ns=positive_number(interval.get("lower_bound")), - ci_upper_ns=positive_number(interval.get("upper_bound")), - ) + try: + estimate = parse_estimate(payload, statistic=statistic) + except ValueError as exc: + raise ValueError(f"{directory / 'estimates.json'}: {statistic}: {exc}") from exc + if estimate.lower is None or estimate.upper is None or estimate.confidence_level != 0.95: + raise ValueError(f"{directory}: expected a complete 95% confidence interval for {statistic}") diff --git a/scripts/performance_artifacts.py b/scripts/performance_artifacts.py index bc4f428..f3618ba 100644 --- a/scripts/performance_artifacts.py +++ b/scripts/performance_artifacts.py @@ -5,18 +5,18 @@ import io import json import math -import os import re -import tempfile from collections.abc import Mapping -from contextlib import contextmanager from dataclasses import dataclass -from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING, Literal, cast +from research_repo_tools.criterion import Estimate +from research_repo_tools.evidence import verify_sha256 +from research_repo_tools.files import replace_many + if TYPE_CHECKING: - from collections.abc import Iterator + from pathlib import Path SCHEMA_VERSION = 1 SUITES = ("all", "exact", "vs_linalg") @@ -125,17 +125,10 @@ class TimingEstimate: def __post_init__(self) -> None: """Reject non-finite, non-positive, or reversed timing intervals.""" - for field, value in ( - ("median_ns", self.median_ns), - ("ci_lower_ns", self.ci_lower_ns), - ("ci_upper_ns", self.ci_upper_ns), - ): - if not math.isfinite(value) or value <= 0: - msg = f"{field} must be finite and positive: {value!r}" - raise ValueError(msg) - if self.ci_lower_ns > self.ci_upper_ns: - msg = f"confidence interval must be ordered: {self.ci_lower_ns} <= {self.ci_upper_ns}" + if self.ci_lower_ns is None or self.ci_upper_ns is None: + msg = "retained timings require a complete confidence interval" raise ValueError(msg) + Estimate(self.median_ns, self.ci_lower_ns, self.ci_upper_ns) @dataclass(frozen=True, slots=True) @@ -900,10 +893,10 @@ def _parse_provenance(payload: bytes, *, source: str) -> tuple[ArtifactContext, def load_bundle_bytes(csv_payload: bytes, provenance_payload: bytes, *, source: str) -> PerformanceBundle: """Parse and validate an artifact pair before it reaches report rendering.""" context, expected_digest, expected_count, _columns = _parse_provenance(provenance_payload, source=source) - observed_digest = hashlib.sha256(csv_payload).hexdigest() - if observed_digest != expected_digest: - msg = f"CSV digest mismatch in {source}: expected {expected_digest}, got {observed_digest}" - raise ValueError(msg) + try: + verify_sha256(csv_payload, expected_digest) + except ValueError as exc: + raise ValueError(f"CSV digest mismatch in {source}: {exc}") from exc rows = _parse_rows(csv_payload, source=source) if len(rows) != expected_count: msg = f"CSV row count mismatch in {source}: expected {expected_count}, got {len(rows)}" @@ -924,104 +917,13 @@ def load_bundle(paths: ArtifactPaths) -> PerformanceBundle: ) -def _stage_payload(path: Path, payload: bytes) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - staged: Path | None = None - try: - with tempfile.NamedTemporaryFile("wb", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as tmp: - staged = Path(tmp.name) - tmp.write(payload) - tmp.flush() - os.fsync(tmp.fileno()) - except BaseException: - if staged is not None: - staged.unlink(missing_ok=True) - raise - if staged is None: - msg = "temporary artifact staging completed without a path" - raise AssertionError(msg) - return staged - - -def _atomic_restore(path: Path, payload: bytes | None) -> None: - if payload is None: - path.unlink(missing_ok=True) - return - staged = _stage_payload(path, payload) - try: - _replace_path(staged, path) - finally: - staged.unlink(missing_ok=True) - - -def _publish_payloads(paths: ArtifactPaths, csv_payload: bytes, provenance_payload: bytes) -> None: - staged_paths: list[Path] = [] - try: - staged_csv = _stage_payload(paths.csv, csv_payload) - staged_paths.append(staged_csv) - staged_provenance = _stage_payload(paths.provenance, provenance_payload) - staged_paths.append(staged_provenance) - previous_csv = paths.csv.read_bytes() if paths.csv.is_file() else None - previous_provenance = paths.provenance.read_bytes() if paths.provenance.is_file() else None - try: - _replace_path(staged_csv, paths.csv) - _replace_path(staged_provenance, paths.provenance) - load_bundle(paths) - except BaseException as publication_error: - rollback_errors = _restore_artifact_pair(paths, previous_csv, previous_provenance) - if rollback_errors: - group_message = "release-performance artifact publication and rollback failed" - raise BaseExceptionGroup( - group_message, - [publication_error, *rollback_errors], - ) from None - raise - finally: - for staged in staged_paths: - staged.unlink(missing_ok=True) - - -def _replace_path(source: Path, destination: Path) -> None: - """Atomically replace *destination* with a staged file.""" - source.replace(destination) - - -def _restore_artifact_pair( - paths: ArtifactPaths, - previous_csv: bytes | None, - previous_provenance: bytes | None, -) -> tuple[BaseException, ...]: - """Attempt both artifact restorations and return every rollback failure.""" - errors: list[BaseException] = [] - for path, payload in ((paths.csv, previous_csv), (paths.provenance, previous_provenance)): - try: - _atomic_restore(path, payload) - except BaseException as exc: # noqa: BLE001 - errors.append(exc) - return tuple(errors) - - -@contextmanager -def publish_bundle(paths: ArtifactPaths, bundle: PerformanceBundle) -> Iterator[None]: - """Publish a validated pair and roll it back if a downstream promotion fails.""" +def bundle_outputs(paths: ArtifactPaths, bundle: PerformanceBundle) -> dict[Path, bytes]: + """Validate serialized legacy evidence before composing its publication.""" + ensure_distinct_paths({"artifact CSV": paths.csv, "artifact provenance": paths.provenance}) csv_payload, provenance_payload = serialize_bundle(bundle) - previous_csv = paths.csv.read_bytes() if paths.csv.is_file() else None - previous_provenance = paths.provenance.read_bytes() if paths.provenance.is_file() else None - _publish_payloads(paths, csv_payload, provenance_payload) - try: - yield - except BaseException as downstream_error: - rollback_errors = _restore_artifact_pair(paths, previous_csv, previous_provenance) - if rollback_errors: - group_message = "release-performance downstream publication and rollback failed" - raise BaseExceptionGroup( - group_message, - [downstream_error, *rollback_errors], - ) from None - raise + return {paths.csv: csv_payload, paths.provenance: provenance_payload} def write_bundle(paths: ArtifactPaths, bundle: PerformanceBundle) -> None: - """Atomically publish a complete validated artifact pair.""" - csv_payload, provenance_payload = serialize_bundle(bundle) - _publish_payloads(paths, csv_payload, provenance_payload) + """Publish the complete validated artifact pair through the shared transaction.""" + replace_many(bundle_outputs(paths, bundle)) diff --git a/scripts/tests/test_archive_performance.py b/scripts/tests/test_archive_performance.py index c817c57..0ba7a39 100644 --- a/scripts/tests/test_archive_performance.py +++ b/scripts/tests/test_archive_performance.py @@ -1813,7 +1813,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** captured = capsys.readouterr() assert rc == 1 - assert "refusing to extract unsafe archive member '../escape.txt'" in captured.err + assert "unsafe or nonportable archive path: '../escape.txt'" in captured.err assert not (tmp_path / "escape.txt").exists() assert not current.exists() assert not any(kind in {"just", "uv"} for kind, _, _ in calls) @@ -2014,9 +2014,19 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert not current.exists() -def _fail_retained_summary_index(_archive: Path) -> None: - msg = "simulated retained summary promotion failure" - raise OSError(msg) +def _fail_publication_once(monkeypatch: pytest.MonkeyPatch, target: Path) -> None: + real_replace = Path.replace + failed = False + + def fail(source: Path, destination: Path) -> Path: + nonlocal failed + if destination == target and not failed: + failed = True + msg = "simulated retained summary promotion failure" + raise OSError(msg) + return real_replace(source, destination) + + monkeypatch.setattr(Path, "replace", fail) @pytest.mark.parametrize("fail_promotion", [False, True]) @@ -2082,7 +2092,7 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** current = tmp_path / "docs" / "performance.md" if fail_promotion: - monkeypatch.setattr(archive_performance, "update_archive_index", _fail_retained_summary_index) + _fail_publication_once(monkeypatch, tmp_path / "docs/archive/performance/README.md") with pytest.raises(OSError, match="simulated retained summary"): archive_performance.render_and_promote_artifacts( artifacts=ArtifactPaths(csv=output.with_suffix(".csv"), provenance=output.with_suffix(".provenance.json")), @@ -2334,69 +2344,6 @@ def fake_run_safe(command: str, args: Sequence[str], cwd: Path | None = None, ** assert not any(kind == "git-stdin" for kind, _, _ in calls) -def test_failed_atomic_replace_preserves_existing_report(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - source = tmp_path / "performance-new.md" - current = tmp_path / "docs" / "performance.md" - archive_dir = tmp_path / "docs" / "archive" / "performance" - original = _report("0.4.5", "v0.4.4") - - source.write_text(_report("0.4.6", "v0.4.5"), encoding="utf-8") - current.parent.mkdir(parents=True) - current.write_text(original, encoding="utf-8") - - def fail_replace(src: Path, dst: Path) -> None: - msg = f"simulated replace failure for {dst}" - raise OSError(msg) - - monkeypatch.setattr(archive_performance, "_replace_file", fail_replace) - - with pytest.raises(OSError, match="simulated replace failure"): - promote_report( - source=source, - current=current, - archive_dir=archive_dir, - expected_current_tag="v0.4.6", - expected_baseline_tag="v0.4.5", - ) - - assert current.read_text(encoding="utf-8") == original - assert not list(current.parent.glob(".performance.md.*.tmp")) - - -@pytest.mark.parametrize("operation", ["write", "restore"]) -@pytest.mark.parametrize("failure_point", ["create", "fsync", "replace"]) -def test_atomic_report_update_failure_preserves_original_and_cleans_temp( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - operation: str, - failure_point: str, -) -> None: - target = tmp_path / "docs" / "performance.md" - target.parent.mkdir() - target.write_text("current\n", encoding="utf-8") - - def fail(*_args: object, **_kwargs: object) -> Never: - msg = f"simulated {failure_point} failure" - raise OSError(msg) - - if failure_point == "create": - monkeypatch.setattr(archive_performance.tempfile, "NamedTemporaryFile", fail) - elif failure_point == "fsync": - monkeypatch.setattr(archive_performance.os, "fsync", fail) - else: - monkeypatch.setattr(archive_performance.Path, "replace", fail) - - if operation == "write": - with pytest.raises(OSError, match=f"simulated {failure_point} failure"): - archive_performance._write_text(target, "updated\n") - else: - with pytest.raises(OSError, match=f"simulated {failure_point} failure"): - archive_performance._restore_file(target, b"restored\n") - - assert target.read_text(encoding="utf-8") == "current\n" - assert list(target.parent.iterdir()) == [target] - - def test_failed_archive_index_update_rolls_back_report_and_new_archive( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -2409,11 +2356,18 @@ def test_failed_archive_index_update_rolls_back_report_and_new_archive( current.parent.mkdir(parents=True) current.write_text(original, encoding="utf-8") - def fail_index(_archive_dir: Path) -> None: - msg = "simulated archive index failure" - raise OSError(msg) + real_replace = Path.replace + failed = False + + def fail_index(source_path: Path, destination: Path) -> Path: + nonlocal failed + if destination == archive_dir / "README.md" and not failed: + failed = True + msg = "simulated archive index failure" + raise OSError(msg) + return real_replace(source_path, destination) - monkeypatch.setattr(archive_performance, "update_archive_index", fail_index) + monkeypatch.setattr(Path, "replace", fail_index) with pytest.raises(OSError, match="simulated archive index failure"): promote_report( @@ -2446,15 +2400,18 @@ def test_failed_artifact_promotion_output_write_rolls_back_report_archive_and_in index.write_text(original_index, encoding="utf-8") output.parent.mkdir(parents=True) output.write_text(original_output, encoding="utf-8") - real_write = archive_performance._write_text + real_replace = Path.replace + failed = False - def fail_output_write(path: Path, text: str) -> None: - if path == output: + def fail_output_write(source: Path, destination: Path) -> Path: + nonlocal failed + if destination == output and not failed: + failed = True msg = "simulated artifact promotion output failure" raise OSError(msg) - real_write(path, text) + return real_replace(source, destination) - monkeypatch.setattr(archive_performance, "_write_text", fail_output_write) + monkeypatch.setattr(Path, "replace", fail_output_write) with pytest.raises(OSError, match="simulated artifact promotion output failure"): archive_performance._promote_report_text( diff --git a/scripts/tests/test_bench_compare.py b/scripts/tests/test_bench_compare.py index 181c4e7..1a3c94e 100644 --- a/scripts/tests/test_bench_compare.py +++ b/scripts/tests/test_bench_compare.py @@ -1,18 +1,15 @@ """Tests for exact-arithmetic benchmark comparison reports.""" import json -import re import subprocess +from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING, Never, cast +from typing import cast import pytest import bench_compare -if TYPE_CHECKING: - from pathlib import Path - _OVERFLOWING_TIMING = 10**400 @@ -296,100 +293,6 @@ def test_read_estimate_no_ci(tmp_path: Path) -> None: assert estimate.ci_hi_ns is None -def test_read_estimate_missing_stat(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - _write_estimates(est, "median", 1.0) - with pytest.raises(KeyError, match="stat 'mean' not found"): - bench_compare._read_estimate(est, "mean") - - -def test_read_estimate_malformed_json_names_file(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - est.write_text("{not json", encoding="utf-8") - - with pytest.raises( - ValueError, - match=re.escape(f"malformed Criterion estimates JSON in {est}"), - ): - bench_compare._read_estimate(est, "median") - - -def test_read_estimate_missing_point_estimate_names_field(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - est.write_text(json.dumps({"median": {}}), encoding="utf-8") - - with pytest.raises(KeyError, match="field 'point_estimate' for stat 'median' not found"): - bench_compare._read_estimate(est, "median") - - -def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - est.write_text( - json.dumps( - { - "median": { - "point_estimate": 1.0, - "confidence_interval": {"lower_bound": "fast", "upper_bound": 2.0}, - } - } - ), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match=r"field 'lower_bound' for stat 'median'.*not numeric"): - bench_compare._read_estimate(est, "median") - - -def test_read_estimate_rejects_numeric_overflow(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - est.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") - - with pytest.raises(ValueError, match=r"field 'point_estimate'.*not numeric") as exc_info: - bench_compare._read_estimate(est, "median") - - assert isinstance(exc_info.value.__cause__, OverflowError) - - -def test_read_estimate_rejects_partial_confidence_interval(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - est.write_text( - json.dumps( - { - "median": { - "point_estimate": 1.0, - "confidence_interval": {"lower_bound": 0.9}, - } - } - ), - encoding="utf-8", - ) - - with pytest.raises(KeyError, match="field 'upper_bound'"): - bench_compare._read_estimate(est, "median") - - -def test_read_estimate_rejects_reversed_confidence_interval(tmp_path: Path) -> None: - est = tmp_path / "estimates.json" - _write_estimates(est, "median", 10.0, lower=12.0, upper=11.0) - - with pytest.raises(ValueError, match=r"lower_bound 12\.0 exceeds upper_bound 11\.0"): - bench_compare._read_estimate(est, "median") - - -@pytest.mark.parametrize("point", [float("nan"), float("inf"), -1.0, 0.0]) -def test_read_estimate_rejects_invalid_timing(tmp_path: Path, point: float) -> None: - est = tmp_path / "estimates.json" - est.write_text(json.dumps({"median": {"point_estimate": point}}), encoding="utf-8") - - with pytest.raises(ValueError, match="must be finite and positive"): - bench_compare._read_estimate(est, "median") - - -def test_criterion_estimate_rejects_partial_interval_even_when_constructed_directly() -> None: - with pytest.raises(ValueError, match="both bounds or neither"): - bench_compare.CriterionEstimate(point_ns=1.0, ci_lo_ns=0.9, ci_hi_ns=None) - - # --------------------------------------------------------------------------- # collect_results / collect_comparisons # --------------------------------------------------------------------------- @@ -671,6 +574,23 @@ def test_collect_vs_linalg_all_benches_includes_latest_peer_rows(tmp_path: Path) # --------------------------------------------------------------------------- +@pytest.mark.parametrize(("current_ns", "change"), [(5.0, "-50.0%"), (10.0, "+0.0%"), (20.0, "+100.0%")]) +def test_comparison_table_preserves_signed_change(current_ns: float, change: str) -> None: + """The report uses signed elapsed-time change, including positive zero.""" + comparison = bench_compare.Comparison( + suite="exact", + group="exact_d2", + bench="det", + baseline=bench_compare.CriterionEstimate(10.0, None, None), + current=bench_compare.CriterionEstimate(current_ns, None, None), + assessment="unknown", + ) + + table = bench_compare._comparison_tables([comparison], "last") + + assert f"| {change} |" in table + + def test_snapshot_uses_one_table_per_suite_with_case_column(tmp_path: Path) -> None: _build_criterion_tree(tmp_path) results = bench_compare._collect_results(tmp_path, "new", "median") @@ -1055,13 +975,17 @@ def test_main_comparison_refuses_incomplete_coverage_before_writing(tmp_path: Pa assert "## Incomplete Comparison Coverage" in error +@pytest.mark.parametrize("point", [0.0, "10.0", True]) def test_main_rejects_invalid_timing_without_writing_or_traceback( tmp_path: Path, capsys: pytest.CaptureFixture[str], + point: object, ) -> None: criterion_dir = tmp_path / "criterion" group = criterion_dir / "exact_d2" - _write_estimates(group / "det" / "new" / "estimates.json", "median", 0.0) + current = group / "det/new/estimates.json" + current.parent.mkdir(parents=True) + current.write_text(json.dumps({"median": {"point_estimate": point}}), encoding="utf-8") _write_estimates(group / "det" / "last" / "estimates.json", "median", 10.0) output = tmp_path / "report.md" @@ -1072,6 +996,50 @@ def test_main_rejects_invalid_timing_without_writing_or_traceback( assert not output.exists() +@pytest.mark.parametrize(("baseline", "current"), [(1e-300, 1e300), (1e300, 1e-300)]) +def test_main_rejects_unrepresentable_comparison_without_changing_report( + tmp_path: Path, capsys: pytest.CaptureFixture[str], baseline: float, current: float +) -> None: + criterion_dir = tmp_path / "criterion" + group = criterion_dir / "d2/la_stack_lu_solve" + _write_estimates(group / "new/estimates.json", "median", current) + _write_estimates(group / "last/estimates.json", "median", baseline) + output = tmp_path / "report.md" + output.write_bytes(b"prior report\n") + + rc = bench_compare.main(["last", "--suite", "vs_linalg", "--scope", "all-benches", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + captured = capsys.readouterr() + assert "Invalid Criterion comparison data" in captured.err + assert not captured.out + assert output.read_bytes() == b"prior report\n" + + +@pytest.mark.parametrize("kind", ["directory", "symlink"]) +def test_main_snapshot_rejects_invalid_output_without_traceback(tmp_path: Path, capsys: pytest.CaptureFixture[str], kind: str) -> None: + criterion_dir = tmp_path / "criterion" + _build_criterion_tree(criterion_dir) + output = tmp_path / "report.md" + previous = tmp_path / "keep.txt" + previous.write_bytes(b"keep\n") + if kind == "directory": + output.mkdir() + else: + try: + output.symlink_to(previous) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + rc = bench_compare.main(["--snapshot", "--suite", "exact", "--scope", "all-benches", "--criterion-dir", str(criterion_dir), "--output", str(output)]) + + assert rc == 2 + captured = capsys.readouterr() + assert "Could not write benchmark report" in captured.err + assert not captured.out + assert previous.read_bytes() == b"keep\n" + + def test_main_rejects_overflowing_timing_without_writing_or_traceback( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -1258,11 +1226,18 @@ def test_markdown_failure_rolls_back_release_artifact_pair(tmp_path: Path, monke ) monkeypatch.setattr(bench_compare, "_release_artifact_bundle", lambda **_kwargs: bundle) - def fail_markdown(_path: Path, _text: str) -> None: - msg = "simulated Markdown publication failure" - raise OSError(msg) + real_replace = Path.replace + failed = False + + def fail_markdown(source: Path, destination: Path) -> Path: + nonlocal failed + if destination == output and not failed: + failed = True + msg = "simulated Markdown publication failure" + raise OSError(msg) + return real_replace(source, destination) - monkeypatch.setattr(bench_compare, "_write_text_atomic", fail_markdown) + monkeypatch.setattr(Path, "replace", fail_markdown) with pytest.raises(OSError, match="simulated Markdown publication failure"): bench_compare._write_and_render_artifacts( @@ -1284,33 +1259,6 @@ def fail_markdown(_path: Path, _text: str) -> None: assert output.read_text(encoding="utf-8") == "old markdown\n" -@pytest.mark.parametrize("failure_point", ["create", "fsync", "replace"]) -def test_atomic_markdown_write_failure_preserves_original_and_cleans_temp( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - failure_point: str, -) -> None: - output = tmp_path / "performance.md" - output.write_text("old\n", encoding="utf-8") - - def fail(*_args: object, **_kwargs: object) -> Never: - msg = f"simulated {failure_point} failure" - raise OSError(msg) - - if failure_point == "create": - monkeypatch.setattr(bench_compare.tempfile, "NamedTemporaryFile", fail) - elif failure_point == "fsync": - monkeypatch.setattr(bench_compare.os, "fsync", fail) - else: - monkeypatch.setattr(bench_compare.Path, "replace", fail) - - with pytest.raises(OSError, match=f"simulated {failure_point} failure"): - bench_compare._write_text_atomic(output, "new\n") - - assert output.read_text(encoding="utf-8") == "old\n" - assert list(tmp_path.iterdir()) == [output] - - def test_main_v045_comparison_publishes_current_only_rational_rows( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/scripts/tests/test_criterion_dim_plot.py b/scripts/tests/test_criterion_dim_plot.py index 7c3f245..1989525 100644 --- a/scripts/tests/test_criterion_dim_plot.py +++ b/scripts/tests/test_criterion_dim_plot.py @@ -1,6 +1,5 @@ """Tests for Criterion dimension-report generation and README updates.""" -import argparse import hashlib import json import re @@ -8,8 +7,9 @@ import subprocess import tomllib from dataclasses import replace +from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING, cast +from typing import cast import pytest @@ -27,9 +27,6 @@ write_bundle, ) -if TYPE_CHECKING: - from pathlib import Path - _OVERFLOWING_TIMING = 10**400 @@ -144,41 +141,11 @@ def test_gp_quote_escapes_backslashes_and_quotes() -> None: assert criterion_dim_plot._gp_quote("a\\'b") == "'a\\\\\\'b'" -def test_maybe_render_plot_handles_gnuplot_failure(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: - # Simulate gnuplot existing but failing to run (CalledProcessError). - def boom(_req: object) -> None: - raise criterion_dim_plot.subprocess.CalledProcessError(1, ["gnuplot"]) - - monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", boom) - - args = argparse.Namespace(no_plot=False) - req = criterion_dim_plot.PlotRequest( - csv_path=criterion_dim_plot.Path("out.csv"), - out_svg=criterion_dim_plot.Path("out.svg"), - title="t", - stat="median", - dims=(2,), - la_label="la-stack v0.1.2", - na_label="nalgebra v0.34.1", - fa_label="faer v0.24.0", - log_y=False, - ) - - rc = criterion_dim_plot._maybe_render_plot(args, req, skipped=[]) - assert rc == 1 - - captured = capsys.readouterr() - assert "Wrote CSV instead" in captured.err - - def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> None: marker_begin, marker_end = criterion_dim_plot._readme_table_markers("lu_solve", "median", "new") readme = tmp_path / "README.md" - readme.write_text( - f"# Title\nbefore\n{marker_begin}\nold line 1\nold line 2\n{marker_end}\nafter\n", - encoding="utf-8", - ) + readme.write_bytes(f"# Title\r\nbefore\r\n{marker_begin}\r\nold line 1\r\nold line 2\r\n{marker_end}\r\nafter\r\n".encode()) table_md = "| a |\n|---|\n| 1 |" @@ -186,6 +153,8 @@ def test_update_readme_table_replaces_only_between_markers(tmp_path: Path) -> No assert changed is True text = readme.read_text(encoding="utf-8") + assert readme.read_bytes().startswith(b"# Title\r\nbefore\r\n") + assert readme.read_bytes().endswith(b"\r\nafter\r\n") assert "old line 1" not in text assert "old line 2" not in text assert marker_begin in text @@ -202,7 +171,7 @@ def test_update_readme_table_errors_on_missing_markers(tmp_path: Path) -> None: readme = tmp_path / "README.md" readme.write_text("# Title\n", encoding="utf-8") - with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"README markers not found"): + with pytest.raises(ValueError, match="exactly one ordered publication marker pair"): criterion_dim_plot._update_readme_table( readme, "", @@ -217,7 +186,7 @@ def test_update_readme_table_errors_on_out_of_order_markers(tmp_path: Path) -> N readme = tmp_path / "README.md" readme.write_text(f"{marker_end}\n{marker_begin}\n", encoding="utf-8") - with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"out of order"): + with pytest.raises(ValueError, match="exactly one ordered publication marker pair"): criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") @@ -230,7 +199,7 @@ def test_update_readme_table_errors_on_non_unique_markers(tmp_path: Path) -> Non encoding="utf-8", ) - with pytest.raises(criterion_dim_plot.ReadmeMarkerError, match=r"not found or not unique"): + with pytest.raises(ValueError, match="exactly one ordered publication marker pair"): criterion_dim_plot._update_readme_table(readme, marker_begin, marker_end, "| x |") @@ -687,137 +656,16 @@ def test_read_estimate_errors_and_success(tmp_path: Path) -> None: point, lo, hi = criterion_dim_plot._read_estimate(estimates, "median") assert (point, lo, hi) == (5.0, 4.0, 6.0) - with pytest.raises(KeyError, match="stat 'mean' not found"): + with pytest.raises(ValueError, match="Criterion mean must be a JSON object"): criterion_dim_plot._read_estimate(estimates, "mean") -def test_read_estimate_malformed_json_names_file(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text("{not json", encoding="utf-8") - - with pytest.raises(ValueError, match=re.escape(f"malformed Criterion estimates JSON in {estimates}")): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_missing_point_estimate_names_field(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {}}), encoding="utf-8") - - with pytest.raises(KeyError, match="field 'point_estimate' for stat 'median' not found"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_non_numeric_ci_bound_names_field(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text( - json.dumps( - { - "median": { - "point_estimate": 1.0, - "confidence_interval": {"lower_bound": "fast", "upper_bound": 2.0}, - } - } - ), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match=r"field 'lower_bound' for stat 'median'.*not numeric"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_rejects_numeric_overflow(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {"point_estimate": _OVERFLOWING_TIMING}}), encoding="utf-8") - - with pytest.raises(ValueError, match=r"field 'point_estimate'.*not numeric") as exc_info: - criterion_dim_plot._read_estimate(estimates, "median") - - assert isinstance(exc_info.value.__cause__, OverflowError) - - -def test_read_estimate_rejects_missing_or_partial_confidence_interval(tmp_path: Path) -> None: +@pytest.mark.parametrize("interval", [None, {"lower_bound": 4.0}]) +def test_plot_adapter_requires_complete_confidence_interval(tmp_path: Path, interval: dict[str, float] | None) -> None: estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {"point_estimate": 1.0}}), encoding="utf-8") - with pytest.raises(KeyError, match="field 'confidence_interval'"): - criterion_dim_plot._read_estimate(estimates, "median") + estimates.write_text(json.dumps({"median": {"point_estimate": 5.0, "confidence_interval": interval}}), encoding="utf-8") - estimates.write_text( - json.dumps( - { - "median": { - "point_estimate": 1.0, - "confidence_interval": {"lower_bound": 0.9}, - } - } - ), - encoding="utf-8", - ) - with pytest.raises(KeyError, match="field 'upper_bound'"): - criterion_dim_plot._read_estimate(estimates, "median") - - -@pytest.mark.parametrize( - ("payload", "field"), - [ - ({"median": {"point_estimate": True}}, "point_estimate"), - ( - { - "median": { - "point_estimate": 1.0, - "confidence_interval": {"lower_bound": False, "upper_bound": 2.0}, - } - }, - "lower_bound", - ), - ], -) -def test_read_estimate_rejects_boolean_numeric_fields(tmp_path: Path, payload: dict[str, object], field: str) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(TypeError, match=rf"field '{field}' for stat 'median'.*not numeric"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_rejects_nonfinite_time(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {"point_estimate": "NaN"}}), encoding="utf-8") - - with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_rejects_negative_time(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {"point_estimate": -1.0}}), encoding="utf-8") - - with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_rejects_zero_time(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text(json.dumps({"median": {"point_estimate": 0.0}}), encoding="utf-8") - - with pytest.raises(ValueError, match=r"median\.point_estimate.*finite and positive"): - criterion_dim_plot._read_estimate(estimates, "median") - - -def test_read_estimate_rejects_inverted_confidence_interval(tmp_path: Path) -> None: - estimates = tmp_path / "estimates.json" - estimates.write_text( - json.dumps( - { - "median": { - "point_estimate": 5.0, - "confidence_interval": {"lower_bound": 6.0, "upper_bound": 4.0}, - } - } - ), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match="lower bound must be <= upper bound"): + with pytest.raises(ValueError, match=r"confidence interval|upper_bound"): criterion_dim_plot._read_estimate(estimates, "median") @@ -928,66 +776,6 @@ def test_resolve_paths(tmp_path: Path) -> None: assert csv == root / "docs/assets/bench/vs_linalg_lu_solve_median.csv" -def test_maybe_render_plot_no_plot_path(capsys: pytest.CaptureFixture[str]) -> None: - args = argparse.Namespace(no_plot=True) - req = criterion_dim_plot.PlotRequest( - csv_path=criterion_dim_plot.Path("out.csv"), - out_svg=criterion_dim_plot.Path("out.svg"), - title="t", - stat="median", - dims=(2,), - la_label="la", - na_label="na", - fa_label="fa", - log_y=False, - ) - rc = criterion_dim_plot._maybe_render_plot(args, req, skipped=[]) - assert rc == 0 - captured = capsys.readouterr() - assert "Wrote CSV: out.csv" in captured.out - - -def test_maybe_render_plot_success_path(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: - def no_op(_req: object) -> None: - return None - - monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", no_op) - - args = argparse.Namespace(no_plot=False) - req = criterion_dim_plot.PlotRequest( - csv_path=criterion_dim_plot.Path("out.csv"), - out_svg=criterion_dim_plot.Path("out.svg"), - title="t", - stat="median", - dims=(2,), - la_label="la", - na_label="na", - fa_label="fa", - log_y=False, - ) - - rc = criterion_dim_plot._maybe_render_plot(args, req, skipped=["d2 (missing)"]) - assert rc == 0 - captured = capsys.readouterr() - assert "Warning: some dimension groups were skipped:" in captured.out - assert "Wrote CSV: out.csv" in captured.out - assert "Wrote SVG: out.svg" in captured.out - - -def test_maybe_update_readme_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - args = argparse.Namespace( - update_readme=True, - readme="missing.md", - metric="lu_solve", - stat="median", - sample="new", - ) - rc = criterion_dim_plot._maybe_update_readme(tmp_path, args, []) - assert rc == 2 - captured = capsys.readouterr() - assert "No such file or directory" in captured.err - - def test_main_error_paths(tmp_path: Path) -> None: # Missing Criterion directory. rc = criterion_dim_plot.main( @@ -1403,102 +1191,53 @@ def fail_render(_request: criterion_dim_plot.PlotRequest) -> None: assert "old table" in readme.read_text(encoding="utf-8") -def test_artifact_rollback_failure_preserves_backups( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, +def test_main_restores_complete_publication_when_readme_replace_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - destination_one = tmp_path / "one.txt" - destination_two = tmp_path / "two.txt" - staged_one = tmp_path / "staged-one.txt" - staged_two = tmp_path / "staged-two.txt" - backup_dir = tmp_path / "backups" - backup_dir.mkdir() - destination_one.write_text("old one\n", encoding="utf-8") - destination_two.write_text("old two\n", encoding="utf-8") - staged_one.write_text("new one\n", encoding="utf-8") - staged_two.write_text("new two\n", encoding="utf-8") - original_replace = criterion_dim_plot.Path.replace - - def fail_replacement_and_rollback(source: Path, destination: Path) -> Path: - if source == staged_two and destination == destination_two: - msg = "simulated publish failure" - raise OSError(msg) - if source == backup_dir / "backup-0" and destination == destination_one: - msg = "simulated rollback failure" - raise OSError(msg) - return original_replace(source, destination) - - monkeypatch.setattr(criterion_dim_plot.Path, "replace", fail_replacement_and_rollback) - - with pytest.raises(criterion_dim_plot.PublicationRollbackError, match="backups preserved") as exc_info: - criterion_dim_plot._replace_staged_files( - [(staged_one, destination_one), (staged_two, destination_two)], - backup_dir, - ) + _write_benchmark_checkout(tmp_path) + _write_performance_bundle(tmp_path) + _mock_publication_environment(tmp_path, monkeypatch) + readme = tmp_path / "README.md" + before = {readme: _canonical_benchmark_readme("0.0.8").replace("\n", "\r\n").encode()} + for suffix in ("csv", "svg", "provenance.json"): + before[tmp_path / f"docs/assets/bench/vs_linalg_lu_solve_median.{suffix}"] = f"previous {suffix}\n".encode() + for path, payload in before.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) - assert str(backup_dir) in str(exc_info.value) - assert (backup_dir / "backup-0").read_text(encoding="utf-8") == "old one\n" - assert destination_one.read_text(encoding="utf-8") == "new one\n" - assert destination_two.read_text(encoding="utf-8") == "old two\n" + def render(request: criterion_dim_plot.PlotRequest) -> None: + request.out_svg.write_bytes(b"\n") + real_replace = Path.replace -@pytest.mark.parametrize("failure_point", [None, "replace", "rollback"]) -def test_publication_keeps_backups_only_when_rollback_fails( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - failure_point: str | None, -) -> None: - destination_one = tmp_path / "one.txt" - destination_two = tmp_path / "two.txt" - staged_one = tmp_path / "staged-one.txt" - staged_two = tmp_path / "staged-two.txt" - for path, text in ( - (destination_one, "old one\n"), - (destination_two, "old two\n"), - (staged_one, "new one\n"), - (staged_two, "new two\n"), - ): - path.write_text(text, encoding="utf-8") - original_replace = criterion_dim_plot.Path.replace - - def replace(source: Path, destination: Path) -> Path: - if failure_point is not None and source == staged_two: - msg = "simulated publish failure" + def fail_readme(source: Path, destination: Path) -> Path: + if destination == readme: + msg = "README publication failed" raise OSError(msg) - if failure_point == "rollback" and source.name == "backup-0": - msg = "simulated rollback failure" - raise OSError(msg) - return original_replace(source, destination) + return real_replace(source, destination) - monkeypatch.setattr(criterion_dim_plot.Path, "replace", replace) + monkeypatch.setattr(criterion_dim_plot, "_render_svg_with_gnuplot", render) + monkeypatch.setattr(Path, "replace", fail_readme) - published = criterion_dim_plot._publish_staged_files( - [(staged_one, destination_one), (staged_two, destination_two)], - tmp_path, - ) - - assert published is (failure_point is None) - backups = list(tmp_path.glob(".criterion-dim-plot-backup-*")) - stderr = capsys.readouterr().err - if failure_point == "rollback": - assert len(backups) == 1 - assert (backups[0] / "backup-0").read_text(encoding="utf-8") == "old one\n" - assert (backups[0] / "backup-1").read_text(encoding="utf-8") == "old two\n" - assert destination_one.read_text(encoding="utf-8") == "new one\n" - assert destination_two.read_text(encoding="utf-8") == "old two\n" - assert "simulated publish failure" in stderr - assert "simulated rollback failure" in stderr - assert f"backups preserved at {backups[0]}" in stderr - else: - assert backups == [] - expected = "new" if published else "old" - assert destination_one.read_text(encoding="utf-8") == f"{expected} one\n" - assert destination_two.read_text(encoding="utf-8") == f"{expected} two\n" - if published: - assert stderr == "" - else: - assert "simulated publish failure" in stderr + assert criterion_dim_plot.main(["--update-readme"]) == 2 + captured = capsys.readouterr() + assert "README publication failed" in captured.err + assert "Wrote" not in captured.out + assert {path: path.read_bytes() for path in before} == before + + +def test_shared_publication_keeps_existing_plot_when_provenance_target_is_invalid(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + csv_path, provenance = tmp_path / "plot.csv", tmp_path / "plot.provenance.json" + staged_csv, staged_provenance = tmp_path / "candidate.csv", tmp_path / "candidate.json" + csv_path.write_bytes(b"old CSV") + provenance.mkdir() + staged_csv.write_bytes(b"new CSV") + staged_provenance.write_bytes(b"new provenance") + + assert not criterion_dim_plot._publish_staged_files([(staged_csv, csv_path), (staged_provenance, provenance)]) + assert csv_path.read_bytes() == b"old CSV" + assert provenance.is_dir() + assert "could not publish benchmark artifacts" in capsys.readouterr().err def test_repo_root_resolution_uses_working_checkout_for_installed_entrypoint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/scripts/tests/test_performance_artifacts.py b/scripts/tests/test_performance_artifacts.py index 6c68e40..5e511b8 100644 --- a/scripts/tests/test_performance_artifacts.py +++ b/scripts/tests/test_performance_artifacts.py @@ -5,13 +5,15 @@ import io import json from collections.abc import Mapping -from pathlib import Path from types import MappingProxyType -from typing import cast +from typing import TYPE_CHECKING, cast import pytest import performance_artifacts + +if TYPE_CHECKING: + from pathlib import Path from archive_performance import render_and_promote_artifacts from benchmark_summaries import full_summary_paths, resolve_report_paths, retained_outputs, summary_outputs from performance_artifacts import ( @@ -22,9 +24,9 @@ ReleasePair, ReportSource, TimingEstimate, + bundle_outputs, load_bundle, load_bundle_bytes, - publish_bundle, serialize_bundle, write_bundle, ) @@ -236,12 +238,6 @@ def _replace_csv_and_digest(csv_payload: bytes, provenance_payload: bytes) -> tu return csv_payload, (json.dumps(provenance, indent=2, sort_keys=True) + "\n").encode() -def _simulate_promotion_failure(paths: ArtifactPaths) -> None: - with publish_bundle(paths, _bundle(current_value=7.0)): - msg = "simulated promotion failure" - raise RuntimeError(msg) - - def _write_outputs(outputs: dict[Path, str]) -> None: for path, payload in outputs.items(): path.parent.mkdir(parents=True, exist_ok=True) @@ -619,7 +615,7 @@ def test_bundle_rejects_duplicate_benchmark_keys() -> None: @pytest.mark.parametrize("value", [0.0, -1.0, float("inf"), float("nan")]) def test_timing_rejects_non_positive_or_non_finite_values(value: float) -> None: - with pytest.raises(ValueError, match="must be finite and positive"): + with pytest.raises(ValueError, match="finite positive number"): TimingEstimate(median_ns=value, ci_lower_ns=1.0, ci_upper_ns=2.0) @@ -630,7 +626,7 @@ def test_timing_accepts_ordered_bootstrap_interval_that_excludes_point_estimate( def test_timing_rejects_reversed_interval() -> None: - with pytest.raises(ValueError, match="confidence interval must be ordered"): + with pytest.raises(ValueError, match="lower bound exceeds upper bound"): TimingEstimate(median_ns=10.0, ci_lower_ns=11.0, ci_upper_ns=9.0) @@ -698,114 +694,25 @@ def test_artifact_loader_fails_closed_on_partial_pair(tmp_path: Path) -> None: load_bundle(paths) -def test_stage_payload_removes_temporary_file_when_fsync_fails( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - target = tmp_path / "performance.csv" - - def fail_fsync(_descriptor: int) -> None: - msg = "simulated fsync failure" - raise OSError(msg) - - monkeypatch.setattr(performance_artifacts.os, "fsync", fail_fsync) - - with pytest.raises(OSError, match="simulated fsync failure"): - performance_artifacts._stage_payload(target, b"payload") - - assert not list(tmp_path.glob(".performance.csv.*.tmp")) - - -def test_failed_second_stage_removes_first_temporary_file( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - paths = ArtifactPaths( - csv=tmp_path / "performance.csv", - provenance=tmp_path / "performance.provenance.json", - ) - real_stage_payload = performance_artifacts._stage_payload - - def fail_provenance_stage(path: Path, payload: bytes) -> Path: - if path == paths.provenance: - msg = "simulated provenance staging failure" - raise OSError(msg) - return real_stage_payload(path, payload) - - monkeypatch.setattr(performance_artifacts, "_stage_payload", fail_provenance_stage) - - with pytest.raises(OSError, match="simulated provenance staging failure"): - write_bundle(paths, _bundle()) +def test_bundle_candidates_round_trip_without_publishing(tmp_path: Path) -> None: + paths = ArtifactPaths(csv=tmp_path / "performance.csv", provenance=tmp_path / "performance.provenance.json") + bundle = _bundle() + outputs = bundle_outputs(paths, bundle) - assert not list(tmp_path.glob(".performance.csv.*.tmp")) assert not paths.csv.exists() assert not paths.provenance.exists() - - -def test_failed_second_replace_restores_prior_valid_pair(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - paths = ArtifactPaths( - csv=tmp_path / "performance.csv", - provenance=tmp_path / "performance.provenance.json", - ) - original = _bundle(current_value=8.0) - write_bundle(paths, original) - real_replace = performance_artifacts._replace_path - failed = False - - def fail_provenance_once(source: Path, destination: Path) -> None: - nonlocal failed - if Path(destination) == paths.provenance and not failed: - failed = True - msg = "simulated provenance replace failure" - raise OSError(msg) - real_replace(source, destination) - - monkeypatch.setattr(performance_artifacts, "_replace_path", fail_provenance_once) - - with pytest.raises(OSError, match="simulated provenance replace failure"): - write_bundle(paths, _bundle(current_value=7.0)) - - assert load_bundle(paths) == PerformanceBundle(context=original.context, rows=original.sorted_rows) - - -def test_downstream_promotion_failure_rolls_back_artifact_pair(tmp_path: Path) -> None: - paths = ArtifactPaths( - csv=tmp_path / "performance.csv", - provenance=tmp_path / "performance.provenance.json", + assert load_bundle_bytes(outputs[paths.csv], outputs[paths.provenance], source="candidates") == PerformanceBundle( + context=bundle.context, rows=bundle.sorted_rows ) - original = _bundle(current_value=8.0) - write_bundle(paths, original) - - with pytest.raises(RuntimeError, match="simulated promotion failure"): - _simulate_promotion_failure(paths) - assert load_bundle(paths) == PerformanceBundle(context=original.context, rows=original.sorted_rows) +def test_shared_publication_rejects_invalid_pair_before_replacing_csv(tmp_path: Path) -> None: + paths = ArtifactPaths(csv=tmp_path / "performance.csv", provenance=tmp_path / "performance.provenance.json") + paths.csv.write_bytes(b"original CSV") + paths.provenance.mkdir() -def test_rollback_attempts_both_artifacts_when_first_restoration_fails( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - paths = ArtifactPaths( - csv=tmp_path / "performance.csv", - provenance=tmp_path / "performance.provenance.json", - ) - write_bundle(paths, _bundle(current_value=8.0)) - real_restore = performance_artifacts._atomic_restore - restored: list[Path] = [] - - def fail_csv_restore(path: Path, payload: bytes | None) -> None: - restored.append(path) - if path == paths.csv: - msg = "simulated CSV restoration failure" - raise OSError(msg) - real_restore(path, payload) - - monkeypatch.setattr(performance_artifacts, "_atomic_restore", fail_csv_restore) - - with pytest.raises(BaseExceptionGroup, match="downstream publication and rollback failed") as raised: - _simulate_promotion_failure(paths) + with pytest.raises(IsADirectoryError): + write_bundle(paths, _bundle()) - assert restored == [paths.csv, paths.provenance] - assert any(isinstance(error, RuntimeError) for error in raised.value.exceptions) - assert any(isinstance(error, OSError) for error in raised.value.exceptions) + assert paths.csv.read_bytes() == b"original CSV" + assert paths.provenance.is_dir() diff --git a/scripts/tests/test_release_baseline.py b/scripts/tests/test_release_baseline.py index b8d2268..999a860 100644 --- a/scripts/tests/test_release_baseline.py +++ b/scripts/tests/test_release_baseline.py @@ -13,6 +13,7 @@ import pytest import release_baseline +from criterion_measurements import validate_measurement REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW = REPO_ROOT / ".github/workflows/release-benchmarks.yml" @@ -150,15 +151,34 @@ def test_invalid_measurement_blocks_publication(dataset: tuple[Path, Path], corr write_json(directory / "estimates.json", estimates) errors = { "short-samples": "times must contain 100 samples", - "nonfinite": "finite positive timing value", - "reversed-interval": "confidence interval must be ordered", - "wrong-confidence": "expected a 95% confidence interval", + "nonfinite": "finite positive number", + "reversed-interval": "lower bound exceeds upper bound", + "wrong-confidence": "confidence_level requires an interval", "malformed": "Expecting property name", } with pytest.raises(ValueError, match=errors[corruption]): release_baseline.validate(criterion, manifest, BASELINE) +@pytest.mark.parametrize(("filename", "location"), [("sample.json", "times[9]"), ("estimates.json", "mean")]) +def test_measurement_error_identifies_file_and_field(dataset: tuple[Path, Path], filename: str, location: str) -> None: + criterion, _manifest = dataset + directory = criterion / "d2/la_stack_dot/new" + path = directory / filename + data = json.loads(path.read_text(encoding="utf-8")) + if filename == "sample.json": + data["times"][9] = -1.0 + else: + data["mean"]["point_estimate"] = -1.0 + write_json(path, data) + + with pytest.raises(ValueError, match="finite positive number") as error: + validate_measurement(directory) + + assert str(path) in str(error.value) + assert location in str(error.value) + + @pytest.mark.parametrize("baseline", ["../escape", "", "new", "base", "change", "report", "/absolute"]) def test_invalid_baseline_is_rejected(dataset: tuple[Path, Path], baseline: str) -> None: with pytest.raises(ValueError, match="safe, nonreserved"): diff --git a/semgrep.yaml b/semgrep.yaml index 65e2844..b4233d9 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -1025,59 +1025,14 @@ rules: - pattern-regex: '^\s*#\s*\[\s*expect\s*\([^\]\n]*\)\s*\]' - pattern-not-regex: '\breason\s*=' - - id: la-stack.python.git-stdin-binary-transport - languages: - - python - severity: WARNING - message: "Keep Git stdin byte-exact: use text=False and do not enable encoding, errors, or universal_newlines on the subprocess." - metadata: - category: correctness - rationale: "Windows text pipes rewrite LF and CRLF, corrupting hashes, patches, and verbatim tag messages before Git receives them." - paths: - include: - - "/scripts/**/*.py" - - "/tests/semgrep/scripts/**/*.py" - exclude: - - "/scripts/tests/**" - - "/tests/semgrep/scripts/tests/**" - patterns: - - pattern-inside: | - def run_git_command_with_input(...): - ... - - pattern-either: - - pattern: subprocess.run(..., input=$INPUT, ...) - - pattern: subprocess.Popen(...) - - pattern-either: - - patterns: - - pattern-either: - - pattern: subprocess.run(..., input=$INPUT, ...) - - pattern: subprocess.Popen(...) - - pattern-not: $CALL(..., text=False, ...) - - patterns: - - pattern-either: - - pattern: subprocess.run(..., encoding=$ENCODING, ...) - - pattern: subprocess.Popen(..., encoding=$ENCODING, ...) - - pattern-not: $CALL(..., encoding=None, ...) - - patterns: - - pattern-either: - - pattern: subprocess.run(..., errors=$ERRORS, ...) - - pattern: subprocess.Popen(..., errors=$ERRORS, ...) - - pattern-not: $CALL(..., errors=None, ...) - - patterns: - - pattern-either: - - pattern: subprocess.run(..., universal_newlines=$NEWLINES, ...) - - pattern: subprocess.Popen(..., universal_newlines=$NEWLINES, ...) - - pattern-not: $CALL(..., universal_newlines=False, ...) - - pattern-not: $CALL(..., universal_newlines=None, ...) - - id: la-stack.python.git-stdin-use-shared-helper languages: - python severity: WARNING - message: "Send Git stdin through subprocess_utils.run_git_command_with_input so Windows cannot translate the input bytes." + message: "Send Git stdin as bytes through research_repo_tools.process.run_git_bytes." metadata: category: correctness - rationale: "The shared Git-input helper preserves exact encoded input; the generic text wrappers do not make that guarantee." + rationale: "Git patches and hash inputs should retain their original bytes without passing through text encoding choices." paths: include: - "/scripts/**/*.py" @@ -1087,10 +1042,9 @@ rules: - "/tests/semgrep/scripts/tests/**" patterns: - pattern-either: - - pattern: run_git_command(..., input=$INPUT, ...) - - pattern: subprocess_utils.run_git_command(..., input=$INPUT, ...) - pattern: run_safe_command("git", ..., input=$INPUT, ...) - - pattern: subprocess_utils.run_safe_command("git", ..., input=$INPUT, ...) + - pattern: benchmark_process.run_safe_command("git", ..., input=$INPUT, ...) + - pattern: research_repo_tools.process.run_command("git", ..., input=$INPUT, ...) - pattern: subprocess.run(["git", ...], ..., input=$INPUT, ...) - pattern: subprocess.Popen(["git", ...], ..., stdin=subprocess.PIPE, ...) - pattern-not: $CALL(..., input=None, ...) @@ -1211,20 +1165,6 @@ rules: - "/tests/semgrep/scripts/tests/python_exceptions.py" pattern-regex: '^\s*except\s+Exception(?:\s+as\s+\w+)?\s*:' - - id: la-stack.python.no-raw-exception-in-tests - languages: - - python - severity: WARNING - message: "Raise a specific exception type in Python tests instead of raw Exception." - metadata: - category: maintainability - rationale: "Typed exceptions make expected failures easier to diagnose." - paths: - include: - - "/scripts/tests/**/*.py" - - "/tests/semgrep/scripts/tests/python_exceptions.py" - pattern: raise Exception(...) - - id: la-stack.python.no-adhoc-completedprocess-mock languages: - python @@ -1307,7 +1247,7 @@ rules: languages: - python severity: WARNING - message: "Use subprocess_utils command wrappers instead of calling subprocess.run directly." + message: "Use research_repo_tools.process runners or benchmark_process adapters instead of calling subprocess.run directly." metadata: category: security rationale: "Support scripts should inherit the repository's subprocess hardening." @@ -1316,20 +1256,5 @@ rules: - "/scripts/**/*.py" - "/tests/semgrep/scripts/tests/python_exceptions.py" exclude: - - "/scripts/subprocess_utils.py" - "/scripts/tests/**/*.py" pattern: subprocess.run(...) - - - id: la-stack.python.no-untyped-defs-in-scripts - languages: - - python - severity: WARNING - message: "Add an explicit return annotation to script functions." - metadata: - category: maintainability - rationale: "Typed script helpers are easier for ty and reviewers to reason about." - paths: - include: - - "/scripts/**/*.py" - - "/tests/semgrep/scripts/tests/python_exceptions.py" - pattern-regex: '^( {0,4}|\t)(async\s+)?def\s+[A-Za-z_][A-Za-z0-9_]*\([^#\n]*\)\s*:' diff --git a/tests/semgrep/scripts/python_portability.py b/tests/semgrep/scripts/python_portability.py index 1fe5fec..3f152d9 100644 --- a/tests/semgrep/scripts/python_portability.py +++ b/tests/semgrep/scripts/python_portability.py @@ -6,56 +6,28 @@ import tempfile from subprocess import run as run_process from tempfile import NamedTemporaryFile as named_file -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from research_repo_tools import process as shared_process +from research_repo_tools.process import run_command, run_git_bytes as git_input + +import benchmark_process as utils +from benchmark_process import run_git_command, run_safe_command if TYPE_CHECKING: - from collections.abc import Mapping, Sequence from pathlib import Path -import subprocess_utils as utils -from subprocess_utils import run_git_command, run_git_command_with_input as git_input, run_safe_command - - -def run_git_command_with_input(payload: str, argv: Sequence[str], options: Mapping[str, Any]) -> None: - # Original failure: the text defaults were hidden in shared kwargs. - kwargs: dict[str, Any] = {"text": True, "encoding": "utf-8"} - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload, **kwargs) - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload, text=True) - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload.encode(), text=False, encoding="utf-8") - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload.encode(), text=False, errors="strict") - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload.encode(), text=False, universal_newlines=True) - # ruleid: la-stack.python.git-stdin-binary-transport - run_process(argv, input=payload, text=True) - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.Popen(argv, stdin=subprocess.PIPE) - # ruleid: la-stack.python.git-stdin-binary-transport - subprocess.Popen(argv, stdin=subprocess.PIPE, text=False, encoding="utf-8") - - # ok: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload.encode("utf-8"), text=False, **options) - # ok: la-stack.python.git-stdin-binary-transport - run_process(argv, input=payload.encode(), text=False) - # ok: la-stack.python.git-stdin-binary-transport - subprocess.run(argv, input=payload.encode(), text=False, encoding=None, errors=None, universal_newlines=False) - # ok: la-stack.python.git-stdin-binary-transport - subprocess.Popen(argv, stdin=subprocess.PIPE, text=False) - - -def git_input_routing(payload: str, argv: Sequence[str]) -> None: - # ruleid: la-stack.python.git-stdin-use-shared-helper - run_git_command(argv, input=payload) - # ruleid: la-stack.python.git-stdin-use-shared-helper - utils.run_git_command(argv, input=payload) + +def git_input_routing(payload: str, argv: list[str]) -> None: # ruleid: la-stack.python.git-stdin-use-shared-helper run_safe_command("git", argv, input=payload) # ruleid: la-stack.python.git-stdin-use-shared-helper utils.run_safe_command("git", argv, input=payload) # ruleid: la-stack.python.git-stdin-use-shared-helper + run_command("git", argv, input=payload) + # ruleid: la-stack.python.git-stdin-use-shared-helper + shared_process.run_command("git", argv, input=payload) + # ruleid: la-stack.python.git-stdin-use-shared-helper subprocess.run(["git", "hash-object", "--stdin"], input=payload, text=True) # ruleid: la-stack.python.git-stdin-use-shared-helper run_process(["git", "hash-object", "--stdin"], input=payload, text=True) @@ -68,23 +40,25 @@ def git_input_routing(payload: str, argv: Sequence[str]) -> None: process.communicate(payload.encode("utf-8")) # ok: la-stack.python.git-stdin-use-shared-helper - git_input(argv, payload) + git_input(argv, input=payload.encode("utf-8")) # ok: la-stack.python.git-stdin-use-shared-helper - utils.run_git_command_with_input(argv, input_data=payload) + shared_process.run_git_bytes(argv, input=payload.encode("utf-8")) # ok: la-stack.python.git-stdin-use-shared-helper run_git_command(argv) # ok: la-stack.python.git-stdin-use-shared-helper - run_git_command(argv, input=None) - # Ordinary text-mode subprocesses outside the Git-input helper are allowed. - # ok: la-stack.python.git-stdin-use-shared-helper, la-stack.python.git-stdin-binary-transport + shared_process.run_command("git", argv, input=None) + # Ordinary text input to non-Git commands is outside this rule's scope. + # ok: la-stack.python.git-stdin-use-shared-helper run_safe_command("ruff", ["check", "-"], input=payload) - # ok: la-stack.python.git-stdin-use-shared-helper, la-stack.python.git-stdin-binary-transport + # ok: la-stack.python.git-stdin-use-shared-helper + run_command("ruff", ["check", "-"], input=payload) + # ok: la-stack.python.git-stdin-use-shared-helper subprocess.run(["formatter"], input=payload, text=True, encoding="utf-8") - # ok: la-stack.python.git-stdin-use-shared-helper, la-stack.python.git-stdin-binary-transport + # ok: la-stack.python.git-stdin-use-shared-helper process = subprocess.Popen(["formatter"], stdin=subprocess.PIPE, text=True, encoding="utf-8") process.communicate(payload) # Git commands without piped stdin do not need the input helper. - # ok: la-stack.python.git-stdin-use-shared-helper, la-stack.python.git-stdin-binary-transport + # ok: la-stack.python.git-stdin-use-shared-helper subprocess.Popen(["git", "status"], stdout=subprocess.PIPE, text=True) diff --git a/tests/semgrep/scripts/tests/python_exceptions.py b/tests/semgrep/scripts/tests/python_exceptions.py index 2b5ef3f..92e456c 100644 --- a/tests/semgrep/scripts/tests/python_exceptions.py +++ b/tests/semgrep/scripts/tests/python_exceptions.py @@ -2,6 +2,10 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock, Mock +from research_repo_tools.process import run_command + +from benchmark_process import run_safe_command + if TYPE_CHECKING: from pathlib import Path @@ -30,16 +34,6 @@ def catches_specific_exception() -> None: pass -def raises_raw_exception() -> None: - # ruleid: la-stack.python.no-raw-exception-in-tests - raise Exception("too broad") - - -def raises_specific_exception() -> None: - # ok: la-stack.python.no-raw-exception-in-tests - raise RuntimeError("specific failure") - - def implicit_path_read_text_encoding(path: Path) -> None: # ruleid: la-stack.python.explicit-path-text-encoding-in-tests path.read_text() @@ -89,11 +83,8 @@ def direct_subprocess_run() -> None: subprocess.run(["git", "status"], check=False) -# ruleid: la-stack.python.no-untyped-defs-in-scripts -def missing_return_annotation(): # noqa: ANN201 - intentional Semgrep violation fixture - return None - - -# ok: la-stack.python.no-untyped-defs-in-scripts -def explicit_return_annotation() -> None: - return None +def shared_process_runners() -> None: + # ok: la-stack.python.no-direct-subprocess-run-outside-wrapper + run_command("git", ["status"], check=False) + # ok: la-stack.python.no-direct-subprocess-run-outside-wrapper + run_safe_command("git", ["status"], check=False) diff --git a/uv.lock b/uv.lock index b9fafba..d271bc0 100644 --- a/uv.lock +++ b/uv.lock @@ -500,7 +500,7 @@ dev = [ { name = "actionlint-py", specifier = "==1.7.12.25" }, { name = "pytest", specifier = "==9.1.1" }, { name = "research-repo-tools", specifier = "==0.1.7" }, - { name = "ruff", specifier = "==0.16.9" }, + { name = "ruff", specifier = "==0.16.10" }, { name = "semgrep", specifier = "==1.178.0" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.2.0" }, @@ -1063,27 +1063,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, - { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, - { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, - { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, - { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, - { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, - { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] [[package]]