diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb134aa9..9e6cf014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,8 @@ jobs: - name: Test run: pytest --cov=agentrust_trace --cov-report=term-missing + + - name: Portability (non-UTF-8 locale and python -OO) + run: | + LC_ALL=C PYTHONUTF8=0 pytest + PYTHONOPTIMIZE=2 pytest tests/test_review_bar.py diff --git a/conformance/tests/conftest.py b/conformance/tests/conftest.py index 24abf89a..54a7ae4e 100644 --- a/conformance/tests/conftest.py +++ b/conformance/tests/conftest.py @@ -14,7 +14,7 @@ def load_vector(filename): - return json.loads((VECTORS_DIR / filename).read_text()) + return json.loads((VECTORS_DIR / filename).read_text(encoding="utf-8")) @pytest.fixture(name="load_vector") @@ -23,7 +23,7 @@ def load_vector_fixture(): def load_schema(): - return json.loads((SCHEMAS_DIR / "trace-claim.json").read_text()) + return json.loads((SCHEMAS_DIR / "trace-claim.json").read_text(encoding="utf-8")) def _b64url(b: bytes) -> str: diff --git a/conformance/tests/test_anchor_vectors.py b/conformance/tests/test_anchor_vectors.py index 6c04d2e8..0ac60661 100644 --- a/conformance/tests/test_anchor_vectors.py +++ b/conformance/tests/test_anchor_vectors.py @@ -11,7 +11,9 @@ from trace_tests.modules import tr_anc VECTOR_DIR = Path(__file__).parent / "vectors" / "anchor-inclusion" -VECTORS = [json.loads(p.read_text()) for p in sorted(VECTOR_DIR.glob("[0-9][0-9]-*.json"))] +VECTORS = [ + json.loads(p.read_text(encoding="utf-8")) for p in sorted(VECTOR_DIR.glob("[0-9][0-9]-*.json")) +] @pytest.mark.level2 diff --git a/conformance/tests/test_manifest_ref_draft_vectors.py b/conformance/tests/test_manifest_ref_draft_vectors.py index 66e45967..3705c63f 100644 --- a/conformance/tests/test_manifest_ref_draft_vectors.py +++ b/conformance/tests/test_manifest_ref_draft_vectors.py @@ -19,7 +19,9 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey VECTOR_DIR = Path(__file__).parent / "vectors" / "manifest-ref-v0.3-draft" -VECTORS = [json.loads(p.read_text()) for p in sorted(VECTOR_DIR.glob("[0-9][0-9]-*.json"))] +VECTORS = [ + json.loads(p.read_text(encoding="utf-8")) for p in sorted(VECTOR_DIR.glob("[0-9][0-9]-*.json")) +] MUTANTS = ( "trusts_asserted_result", "unresolved_is_pass", diff --git a/conformance/tests/test_measurement_harness.py b/conformance/tests/test_measurement_harness.py index d20a41db..1afa8d95 100644 --- a/conformance/tests/test_measurement_harness.py +++ b/conformance/tests/test_measurement_harness.py @@ -10,8 +10,8 @@ from `main()` left the whole suite green, which is precisely the property the script was written to detect, one level up. -**Method.** Each test builds a synthetic checkout — the smallest tree with the shape the -script requires — breaks one precondition, runs the script as a subprocess, and asserts +**Method.** Each test builds a synthetic checkout, the smallest tree with the shape the +script requires, breaks one precondition, runs the script as a subprocess, and asserts it refuses rather than reports. The synthetic tree carries one guarded check and one unguarded one, so the reporting path is exercised too and not only the aborts. @@ -94,13 +94,14 @@ def _checkout(root: Path, *, module: str = MODULE, suite: str = SUITE) -> Path: (package / "modules").mkdir(parents=True) (root / "tests").mkdir() (root / "pyproject.toml").write_text( - '[tool.pytest.ini_options]\ntestpaths = ["tests"]\npythonpath = ["src"]\n' + '[tool.pytest.ini_options]\ntestpaths = ["tests"]\npythonpath = ["src"]\n', + encoding="utf-8", ) - (package / "__init__.py").write_text("") - (package / "result.py").write_text(RESULT) - (package / "modules" / "__init__.py").write_text("") - (package / "modules" / "tr_fak.py").write_text(module) - (root / "tests" / "test_fake.py").write_text(suite) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "result.py").write_text(RESULT, encoding="utf-8") + (package / "modules" / "__init__.py").write_text("", encoding="utf-8") + (package / "modules" / "tr_fak.py").write_text(module, encoding="utf-8") + (root / "tests" / "test_fake.py").write_text(suite, encoding="utf-8") return root @@ -108,7 +109,7 @@ def _run(checkout: Path, *, pythonpath: str = "src") -> subprocess.CompletedProc """Run the harness against *checkout*, as a person would. `PYTHONPATH` is relative and resolved by each subprocess against its own working - directory, which the script sets to the checkout — so "src" reaches that tree's + directory, which the script sets to the checkout, so "src" reaches that tree's package and stands in for the editable install a real checkout has. """ return subprocess.run( @@ -154,9 +155,9 @@ def test_the_module_is_restored_after_the_run(tmp_path: Path) -> None: corrupted the thing it was measuring.""" checkout = _checkout(tmp_path) module = checkout / "src" / "trace_tests" / "modules" / "tr_fak.py" - before = module.read_text() + before = module.read_text(encoding="utf-8") _run(checkout) - assert module.read_text() == before + assert module.read_text(encoding="utf-8") == before # --- the guards, one broken precondition each --------------------------------------- @@ -244,7 +245,7 @@ def test_a_suite_that_imports_another_tree_is_refused(tmp_path: Path) -> None: A stale editable install leaves the baseline green and every mutation unobserved: pytest never imports the file being rewritten, so every site reports that nothing - noticed — which reads exactly like a suite that guards nothing. + noticed, which reads exactly like a suite that guards nothing. """ measured = _checkout(tmp_path / "measured") imported = _checkout(tmp_path / "imported") @@ -257,7 +258,9 @@ def test_a_suite_that_imports_another_tree_is_refused(tmp_path: Path) -> None: def test_an_uninstallable_package_is_refused_rather_than_measured(tmp_path: Path) -> None: """The probe's own failure is a refusal too, not something to measure past.""" checkout = _checkout(tmp_path) - (checkout / "src" / "trace_tests" / "__init__.py").write_text("raise ImportError('broken')\n") + (checkout / "src" / "trace_tests" / "__init__.py").write_text( + "raise ImportError('broken')\n", encoding="utf-8" + ) result = _run(checkout) assert result.returncode != 0 assert "could not import trace_tests" in result.stderr diff --git a/conformance/tests/unit/test_cli.py b/conformance/tests/unit/test_cli.py index c861e500..86dff270 100644 --- a/conformance/tests/unit/test_cli.py +++ b/conformance/tests/unit/test_cli.py @@ -15,10 +15,10 @@ @pytest.fixture def fresh_level0_path(tmp_path): - vector = json.loads((VECTORS_DIR / "valid_level0.json").read_text()) + vector = json.loads((VECTORS_DIR / "valid_level0.json").read_text(encoding="utf-8")) vector["iat"] = int(time.time()) - 60 p = tmp_path / "record.json" - p.write_text(json.dumps(vector)) + p.write_text(json.dumps(vector), encoding="utf-8") return str(p) @@ -52,19 +52,19 @@ def test_unsigned_record_level_0_reports_unverified(fresh_level0_path): def test_stale_record_fails(tmp_path): - vector = json.loads((VECTORS_DIR / "valid_level0.json").read_text()) + vector = json.loads((VECTORS_DIR / "valid_level0.json").read_text(encoding="utf-8")) vector["iat"] = int(time.time()) - (25 * 3600) p = tmp_path / "stale.json" - p.write_text(json.dumps(vector)) + p.write_text(json.dumps(vector), encoding="utf-8") result = CliRunner().invoke(main, ["verify", "--record", str(p), "--level", "0"]) assert result.exit_code == 1, result.output def test_partial_cmcp_envelope_is_rejected(tmp_path): - vector = json.loads((VECTORS_DIR / "valid_cmcp_runtime.json").read_text()) + vector = json.loads((VECTORS_DIR / "valid_cmcp_runtime.json").read_text(encoding="utf-8")) del vector["cmcp_version"] p = tmp_path / "partial.json" - p.write_text(json.dumps(vector)) + p.write_text(json.dumps(vector), encoding="utf-8") result = CliRunner().invoke(main, ["verify", "--record", str(p), "--level", "0"]) assert result.exit_code == 2, result.output assert "partial cmcp-runtime envelope" in result.output diff --git a/conformance/tests/unit/test_loader.py b/conformance/tests/unit/test_loader.py index 1ba3f94a..b88e9a2f 100644 --- a/conformance/tests/unit/test_loader.py +++ b/conformance/tests/unit/test_loader.py @@ -27,7 +27,7 @@ def _write(tmp_path, data): p = tmp_path / "record.json" - p.write_text(json.dumps(data)) + p.write_text(json.dumps(data), encoding="utf-8") return str(p) diff --git a/conformance/tests/unit/test_runner.py b/conformance/tests/unit/test_runner.py index 5e0ca028..bb156c49 100644 --- a/conformance/tests/unit/test_runner.py +++ b/conformance/tests/unit/test_runner.py @@ -12,7 +12,7 @@ def load_vector(filename: str) -> dict: - return json.loads((VECTORS_DIR / filename).read_text()) + return json.loads((VECTORS_DIR / filename).read_text(encoding="utf-8")) @pytest.fixture diff --git a/conformance/tests/vectors/manifest-ref-v0.3-draft/generate.py b/conformance/tests/vectors/manifest-ref-v0.3-draft/generate.py index 165f4067..26e30f16 100644 --- a/conformance/tests/vectors/manifest-ref-v0.3-draft/generate.py +++ b/conformance/tests/vectors/manifest-ref-v0.3-draft/generate.py @@ -144,4 +144,8 @@ def vectors() -> list[dict]: if __name__ == "__main__": for v in vectors(): - (HERE / f"{v['name']}.json").write_text(json.dumps(v, indent=2, ensure_ascii=False) + "\n") + (HERE / f"{v['name']}.json").write_text( + json.dumps(v, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + newline="\n", + ) diff --git a/examples/verifier-token-conformance/gen_corpus.py b/examples/verifier-token-conformance/gen_corpus.py index ce34805a..742fda93 100644 --- a/examples/verifier-token-conformance/gen_corpus.py +++ b/examples/verifier-token-conformance/gen_corpus.py @@ -2402,7 +2402,7 @@ def instance_preimage(tok): def legacy() -> None: token, reqs = base() for number, (stem, (req, desc)) in enumerate(sorted(LEGACY_MAP.items()), start=1): - old = json.loads((LEGACY / (stem + ".json")).read_text()) + old = json.loads((LEGACY / (stem + ".json")).read_text(encoding="utf-8")) text = old["envelope_b64"] raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) code = old["expected"] diff --git a/pyproject.toml b/pyproject.toml index 3b0d5aeb..77daa504 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ # `revocation.py` resolves the bundle schema's statement `$ref` from the packaged # copy through a `referencing.Registry`, so validation never fetches. `referencing` # already arrives through `jsonschema`; it is declared because it is imported. - "referencing>=0.28", + "referencing>=0.28.4", "rfc8785>=0.1.4", ] diff --git a/requirements/dev.in b/requirements/dev.in index c38a73d5..e3a0e021 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -8,7 +8,7 @@ pydantic>=2.13.5 jsonschema>=4.26.0 rfc3986-validator>=0.1.1 cryptography>=42.0 -referencing>=0.28 +referencing>=0.28.4 rfc8785>=0.1.4 # prototype extra: the experimental verifier-token reference and its tests cbor2>=5.6,<7 diff --git a/tests/test_acta_fixtures.py b/tests/test_acta_fixtures.py index 011b9f1b..ecc80629 100644 --- a/tests/test_acta_fixtures.py +++ b/tests/test_acta_fixtures.py @@ -23,7 +23,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey ACTA_DIR = Path(__file__).resolve().parents[1] / "examples" / "action-receipts" / "acta" -EXPECTED = json.loads((ACTA_DIR / "expected.json").read_text()) +EXPECTED = json.loads((ACTA_DIR / "expected.json").read_text(encoding="utf-8")) FIXTURES = sorted(p.name for p in ACTA_DIR.glob("0*.json")) SIGNER_KEY = Ed25519PublicKey.from_public_bytes( @@ -37,7 +37,7 @@ def _jcs(obj) -> bytes: def _load(name: str) -> dict: - return json.loads((ACTA_DIR / name).read_text()) + return json.loads((ACTA_DIR / name).read_text(encoding="utf-8")) def _signature_verifies(envelope: dict) -> bool: @@ -144,6 +144,8 @@ def test_key_mismatch_fixture_is_signed_by_the_committed_second_key(): """03 is a real signature by the committed mismatched key, not random bytes.""" env = _load("03-signature-key-mismatch.json") other = Ed25519PublicKey.from_public_bytes( - bytes.fromhex((ACTA_DIR / "mismatched-signer-public-key.txt").read_text().strip()) + bytes.fromhex( + (ACTA_DIR / "mismatched-signer-public-key.txt").read_text(encoding="utf-8").strip() + ) ) other.verify(bytes.fromhex(env["signature"]["sig"]), _jcs(env["payload"])) diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index 3db406da..8d27297c 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -30,7 +30,7 @@ def _load(directory: str, outcome_of, codes_of, boundary_of=None) -> list[Vector]: out = [] for path in sorted((EXAMPLES / directory).glob("*.json")): - expected = json.loads(path.read_text())["expected"] + expected = json.loads(path.read_text(encoding="utf-8"))["expected"] v = Vector(path.stem, outcome_of(expected), codes_of(expected)) v.boundary = boundary_of(expected) if boundary_of else None out.append(v) diff --git a/tests/test_azure_execution_binding.py b/tests/test_azure_execution_binding.py index 77f42a9e..16640575 100644 --- a/tests/test_azure_execution_binding.py +++ b/tests/test_azure_execution_binding.py @@ -249,7 +249,7 @@ def _wcm(): import wcm pem = (root / "amd-root.pem").read_bytes() - pinned = json.loads((root / "trust-root.json").read_text())["amd"]["pem_sha256"] + pinned = json.loads((root / "trust-root.json").read_text(encoding="utf-8"))["amd"]["pem_sha256"] assert hashlib.sha256(pem).hexdigest() == pinned trust = wcm.TrustStore() trust.add_root_pem(pem.decode()) @@ -258,8 +258,8 @@ def _wcm(): def test_real_stock_boot_packet_replays_and_is_denied(): wcm, trust = _wcm() - packet = json.loads((FIXTURES / "stock-relabel-packet.json").read_text()) - meta = json.loads((FIXTURES / "stock-relabel-meta.json").read_text()) + packet = json.loads((FIXTURES / "stock-relabel-packet.json").read_text(encoding="utf-8")) + meta = json.loads((FIXTURES / "stock-relabel-meta.json").read_text(encoding="utf-8")) ak = x.authenticate_ak(packet, trust) quote = x.parse_quote(base64.b64decode(packet["tpm_quote_b64"])) events = x.parse_uefi_log(base64.b64decode(packet["uefi_event_log_b64"])) @@ -291,8 +291,8 @@ def test_real_stock_boot_packet_replays_and_is_denied(): def test_legacy_pcr23_binding_accepts_the_same_unexecuted_agent(): wcm, trust = _wcm() - packet = json.loads((FIXTURES / "stock-relabel-packet.json").read_text()) - meta = json.loads((FIXTURES / "stock-relabel-meta.json").read_text()) + packet = json.loads((FIXTURES / "stock-relabel-packet.json").read_text(encoding="utf-8")) + meta = json.loads((FIXTURES / "stock-relabel-meta.json").read_text(encoding="utf-8")) legacy = wcm.AzureSnpVtpmVerifier(trust).verify( packet["legacy_wcm_pcr23_bundle_b64"], expected_nonce=meta["challenge"], @@ -309,12 +309,12 @@ def test_legacy_pcr23_binding_accepts_the_same_unexecuted_agent(): def real(name): - packet = json.loads((FIXTURES / f"{name}-packet.json").read_text()) - return packet, (FIXTURES / f"{name}.challenge").read_text().strip() + packet = json.loads((FIXTURES / f"{name}-packet.json").read_text(encoding="utf-8")) + return packet, (FIXTURES / f"{name}.challenge").read_text(encoding="utf-8").strip() def real_policy(**changes): - p = json.loads((FIXTURES / "execution-policy.json").read_text()) + p = json.loads((FIXTURES / "execution-policy.json").read_text(encoding="utf-8")) policy = x.ExecutionPolicy( required_boot_application=p["required_boot_application"], approved_boot_applications=frozenset(p["approved_boot_applications"]), @@ -329,7 +329,9 @@ def real_policy(**changes): def genoa_trust(): wcm, trust = _wcm() pem = (FIXTURES / "ark-genoa.pem").read_bytes() - pinned = json.loads((FIXTURES / "execution-policy.json").read_text())["ark_genoa_sha256"] + pinned = json.loads((FIXTURES / "execution-policy.json").read_text(encoding="utf-8"))[ + "ark_genoa_sha256" + ] assert hashlib.sha256(pem).hexdigest() == pinned trust.add_root_pem(pem.decode()) return trust diff --git a/tests/test_build_provenance_depth_doc.py b/tests/test_build_provenance_depth_doc.py index da596a23..e4b1302f 100644 --- a/tests/test_build_provenance_depth_doc.py +++ b/tests/test_build_provenance_depth_doc.py @@ -26,13 +26,13 @@ ROOT = Path(__file__).resolve().parents[1] -CANONICAL_SCHEMA = json.loads((ROOT / "schema" / "trace-claim.json").read_text()) +CANONICAL_SCHEMA = json.loads((ROOT / "schema" / "trace-claim.json").read_text(encoding="utf-8")) PACKAGED_SCHEMA = json.loads( - (ROOT / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_text() + (ROOT / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_text(encoding="utf-8") ) -NOTE = (ROOT / "docs" / "build-provenance-depth.md").read_text() -SCHEMA_DOC = (ROOT / "docs" / "schema.md").read_text() -TRUST_LEVELS_DOC = (ROOT / "docs" / "trust-levels.md").read_text() +NOTE = (ROOT / "docs" / "build-provenance-depth.md").read_text(encoding="utf-8") +SCHEMA_DOC = (ROOT / "docs" / "schema.md").read_text(encoding="utf-8") +TRUST_LEVELS_DOC = (ROOT / "docs" / "trust-levels.md").read_text(encoding="utf-8") BUILD_PROVENANCE = CANONICAL_SCHEMA["properties"]["build_provenance"] diff --git a/tests/test_build_provenance_depth_vectors.py b/tests/test_build_provenance_depth_vectors.py index 756df49a..74a4a281 100644 --- a/tests/test_build_provenance_depth_vectors.py +++ b/tests/test_build_provenance_depth_vectors.py @@ -232,7 +232,7 @@ def verify(vector: dict[str, Any], depth: str, rules: Sequence[Rule] = RULES) -> def _load(path: Path) -> dict[str, Any]: - payload: dict[str, Any] = json.loads(path.read_text()) + payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) return payload diff --git a/tests/test_conformance_causal.py b/tests/test_conformance_causal.py index e902a687..bce4fdf9 100644 --- a/tests/test_conformance_causal.py +++ b/tests/test_conformance_causal.py @@ -22,7 +22,7 @@ from prototype import verifier_token as v from tests.test_conformance_corpus import CORPUS, NON_PORTABLE, REQUIREMENTS, load, outcome, vectors -SOURCE = Path(v.__file__).read_text() +SOURCE = Path(v.__file__).read_text(encoding="utf-8") _COUNTER = itertools.count() diff --git a/tests/test_conformance_corpus.py b/tests/test_conformance_corpus.py index 24febd18..95bdbb51 100644 --- a/tests/test_conformance_corpus.py +++ b/tests/test_conformance_corpus.py @@ -150,7 +150,7 @@ def load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) def unb64url(text: str) -> bytes: @@ -245,7 +245,7 @@ def test_vector(path): def test_codes_are_the_published_list(): codes = {c["code"] for c in load(CORPUS / "codes.json")["codes"]} - source = Path(v.__file__).read_text() + source = Path(v.__file__).read_text(encoding="utf-8") tree = ast.parse(source) raised = set() for node in ast.walk(tree): @@ -384,7 +384,7 @@ def test_named_gateway_tests_exist(): for requirement, row in coverage_from_vectors().items(): for name in row["gateway_tests"]: file, function = name.split("::") - tree = ast.parse((ROOT / file).read_text()) + tree = ast.parse((ROOT / file).read_text(encoding="utf-8")) names = { node.name for node in ast.walk(tree) @@ -431,7 +431,7 @@ def write_coverage() -> None: "legacy": sorted(v["id"] for v in vectors() if v["id"].startswith("LEGACY-")), "divergences": DIVERGENCES, } - (CORPUS / "coverage.json").write_text(json.dumps(value, indent=1) + "\n") + (CORPUS / "coverage.json").write_text(json.dumps(value, indent=1) + "\n", encoding="utf-8") if __name__ == "__main__": diff --git a/tests/test_delegation_completeness.py b/tests/test_delegation_completeness.py index d5aeaa43..c43686f5 100644 --- a/tests/test_delegation_completeness.py +++ b/tests/test_delegation_completeness.py @@ -386,7 +386,9 @@ def test_margins_have_not_thinned() -> None: current = {code: len(_margin(code)) for code in RULE_CODES} if not MARGINS_FILE.exists(): - MARGINS_FILE.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n") + MARGINS_FILE.write_text( + json.dumps(current, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) pytest.skip(f"recorded initial margins to {MARGINS_FILE.name}; re-run to enforce") recorded: dict[str, int] = json.loads(MARGINS_FILE.read_text(encoding="utf-8")) diff --git a/tests/test_dependabot_auto_merge.py b/tests/test_dependabot_auto_merge.py index 9da3e1e7..d30f8a22 100644 --- a/tests/test_dependabot_auto_merge.py +++ b/tests/test_dependabot_auto_merge.py @@ -60,12 +60,16 @@ def _run_gate(tmp_path: Path, metadata: str, allowed: bool) -> None: pytest.skip("Bash/jq execution matches the workflow's Ubuntu runner") # Extract this workflow's literal script without adding a YAML dependency # to the reference library's development environment. - block = re.search(r"^ run: \|\n((?: [^\n]*\n)+)", WORKFLOW.read_text(), re.M) + block = re.search( + r"^ run: \|\n((?: [^\n]*\n)+)", + WORKFLOW.read_text(encoding="utf-8"), + re.M, + ) assert block is not None, "auto-merge must retain its literal Bash block" script = textwrap.dedent(block[1]) gh = tmp_path / "gh" output = tmp_path / "gh-args" - gh.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$AUDIT_GH_ARGS"\n') + gh.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$AUDIT_GH_ARGS"\n', encoding="utf-8") gh.chmod(0o755) head = "a" * 40 url = "https://github.com/example/repo/pull/1" @@ -88,7 +92,7 @@ def _run_gate(tmp_path: Path, metadata: str, allowed: bool) -> None: ) assert output.exists() is allowed if allowed: - assert output.read_text().splitlines() == [ + assert output.read_text(encoding="utf-8").splitlines() == [ "pr", "merge", "--auto", @@ -100,7 +104,7 @@ def _run_gate(tmp_path: Path, metadata: str, allowed: bool) -> None: def test_workflow_keeps_event_and_permission_boundaries() -> None: - source = WORKFLOW.read_text() + source = WORKFLOW.read_text(encoding="utf-8") assert re.search( r"^on:\n pull_request:\n types: \[opened, reopened, synchronize\]\n" r" branches: \[main\]\n\npermissions:", diff --git a/tests/test_docs_quickstart.py b/tests/test_docs_quickstart.py index c41922ee..1e6de0e1 100644 --- a/tests/test_docs_quickstart.py +++ b/tests/test_docs_quickstart.py @@ -7,7 +7,7 @@ def test_first_record_tutorial(tmp_path): page = Path(__file__).resolve().parents[1] / "docs/quickstart.md" - blocks = re.findall(r"^```python\n(.*?)^```", page.read_text(), re.M | re.S) + blocks = re.findall(r"^```python\n(.*?)^```", page.read_text(encoding="utf-8"), re.M | re.S) assert len(blocks) == 2 first = subprocess.run( [sys.executable, "-c", blocks[0]], cwd=tmp_path, text=True, capture_output=True diff --git a/tests/test_gap_disclosure_fixtures.py b/tests/test_gap_disclosure_fixtures.py index 79eaea46..d168abf6 100644 --- a/tests/test_gap_disclosure_fixtures.py +++ b/tests/test_gap_disclosure_fixtures.py @@ -241,7 +241,7 @@ def test_fixture_set_is_complete() -> None: @pytest.mark.parametrize("path", FIXTURES, ids=lambda p: p.stem) def test_fixture(path: Path) -> None: - doc = json.loads(path.read_text()) + doc = json.loads(path.read_text(encoding="utf-8")) result = verify_gap_disclosure( doc["gap_disclosure"], doc["context"], doc["trusted_issuer_keys"] ) @@ -263,8 +263,10 @@ def test_the_live_tail_is_not_granted_what_the_seal_earns() -> None: advisory, never an outcome. Re-verification after resumption upgrades or impeaches the disclosure on the seal that then exists. """ - sealed = json.loads((FIXTURE_DIR / "01-gap-disclosed-valid.json").read_text()) - tail = json.loads((FIXTURE_DIR / "17-gap-disclosure-at-unsealed-tail.json").read_text()) + sealed = json.loads((FIXTURE_DIR / "01-gap-disclosed-valid.json").read_text(encoding="utf-8")) + tail = json.loads( + (FIXTURE_DIR / "17-gap-disclosure-at-unsealed-tail.json").read_text(encoding="utf-8") + ) assert sealed["gap_disclosure"] == tail["gap_disclosure"], ( "the contrast only isolates the seal if the disclosures are identical" ) @@ -290,7 +292,7 @@ def test_every_registered_rule_is_load_bearing_for_two_fixtures() -> None: being unenforced.""" counts: dict[str, int] = {} for path in FIXTURES: - expected = json.loads(path.read_text())["expected"] + expected = json.loads(path.read_text(encoding="utf-8"))["expected"] for code in [*expected["failures"], *expected["warnings"]]: if code != DISCLOSED: counts[code] = counts.get(code, 0) + 1 diff --git a/tests/test_integer_by_value.py b/tests/test_integer_by_value.py index 098d1cec..3f059439 100644 --- a/tests/test_integer_by_value.py +++ b/tests/test_integer_by_value.py @@ -152,7 +152,9 @@ def test_a_boolean_is_refused_although_python_calls_it_an_int() -> None: def _base() -> dict[str, Any]: """Vector 01's record, unsigned, carrying all five integer members of the schema.""" - record = json.loads((VECTORS / "01-integer-spelling-verified.json").read_text())["record"] + record = json.loads( + (VECTORS / "01-integer-spelling-verified.json").read_text(encoding="utf-8") + )["record"] record.pop("signature") record["tool_transcript"] = {"hash": "sha256:" + "c" * 64, "call_count": 7} record["origin"] = {"kind": "self", "producer": "producer.example", @@ -243,7 +245,7 @@ def test_the_schema_and_the_model_draw_each_boundary_in_the_same_place( decided by Python type, and read a numeric string as the number it spells.""" record = _set(_base(), member, json.loads(text)) errors = list(jsonschema.Draft202012Validator( - json.loads((ROOT / "schema" / "trace-claim.json").read_text()) + json.loads((ROOT / "schema" / "trace-claim.json").read_text(encoding="utf-8")) ).iter_errors(record)) if keyword is None: assert errors == [] @@ -694,7 +696,7 @@ def test_a_successor_artifact_time_that_is_not_an_integer_is_refused( def test_a_revocation_bundle_decides_its_times_by_value() -> None: doc = json.loads( (ROOT / "examples" / "revocation-bundle" / "01-fresh-well-inside-both-bounds.json") - .read_text() + .read_text(encoding="utf-8") ) ctx = doc["context"] diff --git a/tests/test_models.py b/tests/test_models.py index 90b34823..ca6d659c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -13,7 +13,7 @@ def _load(name: str) -> dict: # Examples must validate exactly as published: no preprocessing. - return json.loads((EXAMPLES_DIR / name).read_text()) + return json.loads((EXAMPLES_DIR / name).read_text(encoding="utf-8")) @pytest.mark.parametrize("filename", ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json"]) diff --git a/tests/test_multihost_replay.py b/tests/test_multihost_replay.py index 1a0e159f..61b5c1c1 100644 --- a/tests/test_multihost_replay.py +++ b/tests/test_multihost_replay.py @@ -64,7 +64,7 @@ def child_env() -> dict[str, str]: def _replica_main() -> None: """JSON lines on stdin/stdout; one gate per process, rebuilt by ``store``.""" name = sys.argv[1] - fixture_ = json.loads((VECTORS / "01-valid.json").read_text()) + fixture_ = json.loads((VECTORS / "01-valid.json").read_text(encoding="utf-8")) ctx = context(fixture_) raw = unb64(fixture_["envelope_b64"]) state: dict = {"gate": None, "sent": 0, "clock": g.NOW} @@ -305,7 +305,8 @@ def call_action(call_id: str, session: str = SESSION) -> dict: def raw_token() -> bytes: - return unb64(json.loads((VECTORS / "01-valid.json").read_text())["envelope_b64"]) + doc = json.loads((VECTORS / "01-valid.json").read_text(encoding="utf-8")) + return unb64(doc["envelope_b64"]) def admit_on(replica: Replica, minted_by: Replica | None = None) -> None: diff --git a/tests/test_provenance_depth.py b/tests/test_provenance_depth.py index c2603065..ccfd871c 100644 --- a/tests/test_provenance_depth.py +++ b/tests/test_provenance_depth.py @@ -23,9 +23,9 @@ ROOT = Path(__file__).resolve().parents[1] -CANONICAL = json.loads((ROOT / "schema" / "trace-claim.json").read_text()) +CANONICAL = json.loads((ROOT / "schema" / "trace-claim.json").read_text(encoding="utf-8")) PACKAGED = json.loads( - (ROOT / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_text() + (ROOT / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_text(encoding="utf-8") ) DEPTHS = ("surface", "builder", "transitive") @@ -112,6 +112,6 @@ def test_claimed_and_verified_are_separate_fields() -> None: def test_docs_table_lists_both_fields() -> None: """docs/schema.md is the human copy of the same two fields; drift is silent otherwise.""" - schema_doc = (ROOT / "docs" / "schema.md").read_text() + schema_doc = (ROOT / "docs" / "schema.md").read_text(encoding="utf-8") assert "`provenance_depth`" in schema_doc assert "`provenance_depth_verified`" in schema_doc diff --git a/tests/test_regex_surface_parity.py b/tests/test_regex_surface_parity.py index 91dcaf19..44a9dde0 100644 --- a/tests/test_regex_surface_parity.py +++ b/tests/test_regex_surface_parity.py @@ -12,8 +12,10 @@ from agentrust_trace.models import DigestStr from pathlib import Path -BASE = json.loads((Path(__file__).parents[1] / - "examples/canonicalization-boundary/01-non-ascii-values.json").read_text())["record"] +BASE = json.loads( + (Path(__file__).parents[1] / "examples/canonicalization-boundary/01-non-ascii-values.json") + .read_text(encoding="utf-8") +)["record"] def accepts(call): diff --git a/tests/test_requirement_keywords_are_attributable.py b/tests/test_requirement_keywords_are_attributable.py index 8228d7fe..86c7c1d1 100644 --- a/tests/test_requirement_keywords_are_attributable.py +++ b/tests/test_requirement_keywords_are_attributable.py @@ -140,9 +140,9 @@ def test_a_keyword_inside_a_code_fence_is_not_a_requirement(tmp_path: Path) -> N guard by rewording their examples, which is worse than not having it.""" doc = tmp_path / "docs" / "sample.md" doc.parent.mkdir() - doc.write_text("```\nthis MUST not be scanned\n```\nordinary prose\n") + doc.write_text("```\nthis MUST not be scanned\n```\nordinary prose\n", encoding="utf-8") in_fence, hits = False, [] - for line in doc.read_text().splitlines(): + for line in doc.read_text(encoding="utf-8").splitlines(): if line.strip().startswith("```"): in_fence = not in_fence continue diff --git a/tests/test_review_bar.py b/tests/test_review_bar.py index 4ef86272..4b214257 100644 --- a/tests/test_review_bar.py +++ b/tests/test_review_bar.py @@ -9,6 +9,7 @@ from __future__ import annotations import importlib.util +import os import shutil import subprocess import sys @@ -125,7 +126,7 @@ def _commit(repo: Path, files: dict[str, str], message: str) -> str: for path, text in files.items(): target = repo / path target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(text) + target.write_text(text, encoding="utf-8") subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) subprocess.run( ["git", "-C", str(repo), "-c", "user.name=t", "-c", "user.email=t@example.com", @@ -192,6 +193,10 @@ def test_changed_files_are_the_added_and_modified_tests_support_and_src(repo): def test_a_test_path_git_would_quote_is_still_found(tmp_path): + try: + os.fsencode("test_été.py") + except UnicodeEncodeError: + pytest.skip("POSIX C locale with PYTHONUTF8=0 cannot encode non-ASCII filenames") _init(tmp_path) base = _commit(tmp_path, {"README": "x\n"}, "base") head = _commit(tmp_path, {"tests/test_été.py": "def test_x():\n pass\n"}, "head") @@ -322,7 +327,7 @@ def test_the_command_line_writes_the_summary_and_never_fails_the_build(repo, tmp ) assert code == 0 heading = "### Changed tests that pass on the head, run against the merge base" - assert heading in summary.read_text() + assert heading in summary.read_text(encoding="utf-8") def test_an_error_inside_the_tool_still_writes_a_report_and_exits_zero(tmp_path): @@ -332,7 +337,7 @@ def test_an_error_inside_the_tool_still_writes_a_report_and_exits_zero(tmp_path) "--summary", str(summary)] ) assert code == 0 - assert "The review bar could not finish" in summary.read_text() + assert "The review bar could not finish" in summary.read_text(encoding="utf-8") def test_child_test_runs_do_not_inherit_the_workflow_file_variables(tmp_path, monkeypatch): @@ -343,7 +348,8 @@ def test_child_test_runs_do_not_inherit_the_workflow_file_variables(tmp_path, mo (tree / "tests" / "test_w.py").write_text( "import os\n\n\ndef test_w():\n" " assert not {'GITHUB_STEP_SUMMARY', 'GITHUB_OUTPUT', 'GITHUB_ENV', 'GITHUB_PATH'}" - " & set(os.environ)\n" + " & set(os.environ)\n", + encoding="utf-8", ) code, outcomes = review_bar.run_tests(tree, ["tests/test_w.py"], 60) assert [o.result for o in outcomes] == ["passed"] @@ -353,7 +359,8 @@ def test_a_test_run_over_its_time_limit_reports_a_timeout(tmp_path): tree = tmp_path / "tree" (tree / "tests").mkdir(parents=True) (tree / "tests" / "test_slow.py").write_text( - "import time\n\n\ndef test_slow():\n time.sleep(30)\n" + "import time\n\n\ndef test_slow():\n time.sleep(30)\n", + encoding="utf-8", ) assert review_bar.run_tests(tree, ["tests/test_slow.py"], 2) == (-1, []) @@ -546,7 +553,8 @@ def test_a_collection_error_is_read_whatever_traceback_style_is_configured(tmp_p tree = tmp_path / "tree" (tree / "tests").mkdir(parents=True) (tree / "tests" / "test_attr.py").write_text( - "import os\n\nos.not_there\n\n\ndef test_x():\n pass\n" + "import os\n\nos.not_there\n\n\ndef test_x():\n pass\n", + encoding="utf-8", ) code, outcomes = review_bar.run_tests(tree, ["tests/test_attr.py"], 60) assert [(o.result, o.detail) for o in outcomes] == [ @@ -557,7 +565,7 @@ def test_a_test_file_is_explained_only_by_its_own_error(tmp_path): # tests/test_mod.py fails while loading. tests/test_mod/test_x.py is a different # file, so a test it did not generate on the base is not explained by that error. (tmp_path / "tests" / "test_mod").mkdir(parents=True) - (tmp_path / "tests" / "test_mod" / "test_x.py").write_text("") + (tmp_path / "tests" / "test_mod" / "test_x.py").write_text("", encoding="utf-8") Outcome = review_bar.Outcome files = {"tests.test_mod": Outcome("tests.test_mod", "behaviour", "assert False")} explain = review_bar._not_collected @@ -573,7 +581,8 @@ def test_colour_forced_in_the_environment_does_not_hide_the_cause(tmp_path, monk tree = tmp_path / "tree" (tree / "tests").mkdir(parents=True) (tree / "tests" / "test_new.py").write_text( - "import not_there_at_all\n\n\ndef test_x():\n pass\n" + "import not_there_at_all\n\n\ndef test_x():\n pass\n", + encoding="utf-8", ) code, outcomes = review_bar.run_tests(tree, ["tests/test_new.py"], 60) assert [(o.result, o.detail) for o in outcomes] == [ @@ -584,7 +593,8 @@ def test_a_collection_error_with_no_error_lines_keeps_pytests_message(tmp_path): tree = tmp_path / "tree" (tree / "tests").mkdir(parents=True) (tree / "tests" / "test_fail.py").write_text( - "import pytest\n\npytest.fail('stop', pytrace=False)\n\n\ndef test_x():\n pass\n" + "import pytest\n\npytest.fail('stop', pytrace=False)\n\n\ndef test_x():\n pass\n", + encoding="utf-8", ) code, outcomes = review_bar.run_tests(tree, ["tests/test_fail.py"], 60) assert [(o.result, o.detail) for o in outcomes] == [("behaviour", "collection failure")] diff --git a/tests/test_schema_regex_semantics.py b/tests/test_schema_regex_semantics.py index 6eb04e86..bc890a83 100644 --- a/tests/test_schema_regex_semantics.py +++ b/tests/test_schema_regex_semantics.py @@ -11,8 +11,10 @@ from agentrust_trace import generate_key, key_to_jwk, sign_record, validate_json, verify_record from agentrust_trace.validate import SCHEMA, iter_errors -BASE = json.loads((Path(__file__).parents[1] / - "examples/canonicalization-boundary/01-non-ascii-values.json").read_text())["record"] +BASE = json.loads( + (Path(__file__).parents[1] / "examples/canonicalization-boundary/01-non-ascii-values.json") + .read_text(encoding="utf-8") +)["record"] TERMINATORS = ["\n", "\r", "\u2028", "\u2029"] diff --git a/tests/test_snp_collateral.py b/tests/test_snp_collateral.py index 08ee3cfb..5f4427e8 100644 --- a/tests/test_snp_collateral.py +++ b/tests/test_snp_collateral.py @@ -21,8 +21,8 @@ def packets(): - milan = json.loads((FIXTURES / "live-milan-packet.json").read_text()) - genoa = json.loads((EXEC / "b1-approved-packet.json").read_text()) + milan = json.loads((FIXTURES / "live-milan-packet.json").read_text(encoding="utf-8")) + genoa = json.loads((EXEC / "b1-approved-packet.json").read_text(encoding="utf-8")) return milan, genoa diff --git a/tests/test_stage4_relationships.py b/tests/test_stage4_relationships.py index 6150aacd..c382f14f 100644 --- a/tests/test_stage4_relationships.py +++ b/tests/test_stage4_relationships.py @@ -45,7 +45,7 @@ def packet(name: str) -> dict[str, Any]: - return json.loads(PACKETS[name].read_text()) + return json.loads(PACKETS[name].read_text(encoding="utf-8")) def cpu_component( diff --git a/tests/test_tdx_collateral.py b/tests/test_tdx_collateral.py index c0f9e6bd..2983cc48 100644 --- a/tests/test_tdx_collateral.py +++ b/tests/test_tdx_collateral.py @@ -68,7 +68,7 @@ def collateral_with(p, name, change): def test_intel_root_is_the_independently_downloaded_pin(): - pin = json.loads((CLOUD / "trust-root.json").read_text())["intel"] + pin = json.loads((CLOUD / "trust-root.json").read_text(encoding="utf-8"))["intel"] der = ROOT.public_bytes(serialization.Encoding.DER) assert hashlib.sha256(der).hexdigest() == pin["der_sha256"] assert hashlib.sha256(QUOTE).hexdigest() == packet("standard").record["quote_sha256"] @@ -191,7 +191,9 @@ def change(record): def test_ten_year_expired_offline_replay_denies(): - replay = json.loads((APPRAISAL / "offline" / "offline-replay.json").read_text()) + replay = json.loads( + (APPRAISAL / "offline" / "offline-replay.json").read_text(encoding="utf-8") + ) for name, policy in ( ("standard", STANDARD), ("early", t.CollateralPolicy("early", 22, frozenset({0xA002}))), @@ -207,7 +209,9 @@ def test_ten_year_expired_offline_replay_denies(): def test_historical_offline_replay_reproduces_the_saved_verdict(): - replay = json.loads((APPRAISAL / "offline" / "offline-replay.json").read_text()) + replay = json.loads( + (APPRAISAL / "offline" / "offline-replay.json").read_text(encoding="utf-8") + ) base = packet("standard") historical = dataclasses.replace( base, record=t.replay_record(replay, base.record, "standard", "historical") @@ -322,9 +326,10 @@ def expiry(record): def test_duplicate_json_keys_deny(tmp_path): for name in ("qvl-appraisal.json", "intel-collateral.json", "sgx_default_qcnl.conf"): (tmp_path / name).write_bytes((APPRAISAL / "standard" / name).read_bytes()) - raw = (tmp_path / "qvl-appraisal.json").read_text() + raw = (tmp_path / "qvl-appraisal.json").read_text(encoding="utf-8") (tmp_path / "qvl-appraisal.json").write_text( - raw.replace('"qve_used": false,', '"qve_used": false, "qve_used": false,') + raw.replace('"qve_used": false,', '"qve_used": false, "qve_used": false,'), + encoding="utf-8", ) denied("json_duplicate_key", t.load_packet, tmp_path) diff --git a/tests/test_validate.py b/tests/test_validate.py index f483fbad..ddcb6592 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -12,7 +12,7 @@ def _load(name: str) -> dict: # Examples must validate exactly as published: no preprocessing. - return json.loads((EXAMPLES_DIR / name).read_text()) + return json.loads((EXAMPLES_DIR / name).read_text(encoding="utf-8")) @pytest.mark.parametrize("filename", ["intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json"]) diff --git a/tests/test_verifier_compatibility_separation.py b/tests/test_verifier_compatibility_separation.py index 4691e43e..cf15c4ab 100644 --- a/tests/test_verifier_compatibility_separation.py +++ b/tests/test_verifier_compatibility_separation.py @@ -158,7 +158,9 @@ def null_verifier(record: dict, trusted_jwk: dict) -> None: def _fixtures() -> dict[str, dict]: - out = {p.stem: json.loads(p.read_text()) for p in sorted(VECTORS.glob("*.json"))} + out = { + p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted(VECTORS.glob("*.json")) + } assert out, "no fixtures loaded; this module would pass while measuring nothing" return out diff --git a/tests/test_verifier_token_mutations.py b/tests/test_verifier_token_mutations.py index 20ceb73e..bf85bf51 100644 --- a/tests/test_verifier_token_mutations.py +++ b/tests/test_verifier_token_mutations.py @@ -22,7 +22,7 @@ def load_mutant(changes): - source = __import__("pathlib").Path(v.__file__).read_text() + source = __import__("pathlib").Path(v.__file__).read_text(encoding="utf-8") for before, after in changes: assert source.count(before) == 1, before source = source.replace(before, after) @@ -139,7 +139,7 @@ def test_disabling_mapped_gate_admits_counterexample(fixture, rule): body = list(__import__("cbor2").loads(raw).value) body[3] = bytes([body[3][0] ^ 1]) + body[3][1:] raw = __import__("cbor2").dumps(__import__("cbor2").CBORTag(18, body), canonical=True) - source = __import__("pathlib").Path(v.__file__).read_text() + source = __import__("pathlib").Path(v.__file__).read_text(encoding="utf-8") tree = ast.parse(source) function = next( n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "verify_token" diff --git a/tests/test_verifier_token_profile.py b/tests/test_verifier_token_profile.py index 27ae0b1a..5a7d6d64 100644 --- a/tests/test_verifier_token_profile.py +++ b/tests/test_verifier_token_profile.py @@ -29,7 +29,7 @@ def unb64(text: str) -> bytes: @pytest.fixture def fixture(): - return json.loads((VECTORS / "01-valid.json").read_text()) + return json.loads((VECTORS / "01-valid.json").read_text(encoding="utf-8")) def context(f): @@ -80,7 +80,7 @@ def test_independent_positive_signature_and_payload(fixture): @pytest.mark.parametrize("path", sorted(VECTORS.glob("*.json")), ids=lambda p: p.stem) def test_portable_vectors(path, fixture): - f = json.loads(path.read_text()) + f = json.loads(path.read_text(encoding="utf-8")) raw = unb64(f["envelope_b64"]) if f["expected"] == "valid": v.verify_token(raw, context(fixture), g.NOW) @@ -467,7 +467,9 @@ def test_schema_contracts_reproduce_models(fixture): ("decision-receipt", v.Receipt), ]: schema = json.loads( - (ROOT / "schema" / ("trace-" + name + "-experimental-v1.json")).read_text() + (ROOT / "schema" / ("trace-" + name + "-experimental-v1.json")).read_text( + encoding="utf-8" + ) ) assert schema == model.model_json_schema() jsonschema.Draft202012Validator.check_schema(schema) @@ -543,7 +545,9 @@ def call(_): ], ) def test_schema_rejects_without_model_gate(fixture, field, value): - schema = json.loads((ROOT / "schema/trace-token-experimental-v1.json").read_text()) + schema = json.loads( + (ROOT / "schema/trace-token-experimental-v1.json").read_text(encoding="utf-8") + ) token = copy.deepcopy(fixture["token"]) token[field] = value with pytest.raises(jsonschema.ValidationError): diff --git a/tools/check_dashes.py b/tools/check_dashes.py index 48c37316..8aa324b5 100644 --- a/tools/check_dashes.py +++ b/tools/check_dashes.py @@ -89,7 +89,9 @@ def findings(root: pathlib.Path): def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser( + description=(__doc__ or "").splitlines()[0] if __doc__ else "" + ) parser.add_argument("--root", default=".") args = parser.parse_args() diff --git a/tools/review_bar.py b/tools/review_bar.py index c2ab1e19..8ba8a382 100644 --- a/tools/review_bar.py +++ b/tools/review_bar.py @@ -76,7 +76,10 @@ def missing_name(message: str, new_names: frozenset[str] = frozenset()) -> bool: def git(repo: Path, *args: str) -> str: proc = subprocess.run( - ["git", "-C", str(repo), *args], capture_output=True, text=True + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + encoding="utf-8", ) if proc.returncode != 0: raise RuntimeError(f"git {args[0]} failed: {proc.stderr.strip()}") @@ -217,7 +220,13 @@ def run_tests( [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-o", "addopts=", "--tb=short", "--continue-on-collection-errors", f"--junitxml={junit}", *tests], - cwd=tree, env=env, capture_output=True, text=True, timeout=timeout, + cwd=tree, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, ) except subprocess.TimeoutExpired: junit.unlink(missing_ok=True) @@ -375,7 +384,7 @@ def _not_collected( def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n", 1)[0]) parser.add_argument("--repo", type=Path, default=Path.cwd()) parser.add_argument("--base", required=True) parser.add_argument("--head", required=True)