From b2c81f0a8ac0caa8e4715f7ca446a070dafb2b24 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:20:43 +0200 Subject: [PATCH 1/6] Add read-only compatibility reports * Check raw-source coverage, explicit failures, semantic round trips, canonical stability, and output encoding. * Distinguish permitted formatting changes from exact source byte identity. * Keep the checker independent of other open changes. --- bibtexparser/__init__.py | 4 + bibtexparser/compatibility.py | 334 ++++++++++++++++++++++++++++++++++ tests/test_compatibility.py | 156 ++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 bibtexparser/compatibility.py create mode 100644 tests/test_compatibility.py diff --git a/bibtexparser/__init__.py b/bibtexparser/__init__.py index 3b7e188f..05e8beae 100644 --- a/bibtexparser/__init__.py +++ b/bibtexparser/__init__.py @@ -1,6 +1,10 @@ import bibtexparser.exceptions import bibtexparser.middlewares import bibtexparser.model +from bibtexparser.compatibility import CompatibilityDiagnostic +from bibtexparser.compatibility import CompatibilityReport +from bibtexparser.compatibility import check_file +from bibtexparser.compatibility import check_string from bibtexparser.entrypoint import parse_file from bibtexparser.entrypoint import parse_string from bibtexparser.entrypoint import write_file diff --git a/bibtexparser/compatibility.py b/bibtexparser/compatibility.py new file mode 100644 index 00000000..8098a196 --- /dev/null +++ b/bibtexparser/compatibility.py @@ -0,0 +1,334 @@ +"""Read-only compatibility checks for user-supplied bibliography data. + +The checker exercises the public default parse/write path without changing the +source file. It verifies named invariants rather than claiming to prove that the +parser is correct for every possible input. In particular, reparsing with the +same implementation cannot independently validate every interpretation choice. +""" + +from dataclasses import asdict +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any + +from .entrypoint import parse_string +from .entrypoint import write_string +from .library import Library +from .model import Entry +from .model import ExplicitComment +from .model import ImplicitComment +from .model import ParsingFailedBlock +from .model import Preamble +from .model import String + + +@dataclass(frozen=True) +class CompatibilityDiagnostic: + """One actionable finding produced by a compatibility check.""" + + code: str + severity: str + message: str + line: int | None = None + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable representation.""" + return asdict(self) + + +@dataclass(frozen=True) +class CompatibilityReport: + """Results of exercising the default codec contract on one source.""" + + source_sha256: str + source_bytes: int + source_lines: int | None + block_count: int | None + entry_count: int | None + source_covered: bool | None + parsed_without_failures: bool | None + semantic_roundtrip: bool | None + canonical_stable: bool | None + output_encodable: bool | None + exact_source_match: bool | None + diagnostics: tuple[CompatibilityDiagnostic, ...] + + @property + def compatible(self) -> bool: + """Whether every required compatibility invariant passed.""" + required_checks = ( + self.source_covered, + self.parsed_without_failures, + self.semantic_roundtrip, + self.canonical_stable, + self.output_encodable, + ) + return all(check is True for check in required_checks) + + def to_dict(self) -> dict[str, Any]: + """Return a stable JSON-serializable representation without source text.""" + return { + "compatible": self.compatible, + "source_sha256": self.source_sha256, + "source_bytes": self.source_bytes, + "source_lines": self.source_lines, + "block_count": self.block_count, + "entry_count": self.entry_count, + "checks": { + "source_covered": self.source_covered, + "parsed_without_failures": self.parsed_without_failures, + "semantic_roundtrip": self.semantic_roundtrip, + "canonical_stable": self.canonical_stable, + "output_encodable": self.output_encodable, + "exact_source_match": self.exact_source_match, + }, + "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics], + } + + +def _library_signature(library: Library) -> tuple[Any, ...]: + """Return ordered bibliography meaning protected by the default contract.""" + signatures: list[tuple[Any, ...]] = [] + for block in library.blocks: + if isinstance(block, Entry): + fields = tuple((field.key, field.value, field.enclosing) for field in block.fields) + comments = tuple( + (comment.comment, comment.field_index) for comment in getattr(block, "comments", ()) + ) + signatures.append(("entry", block.entry_type, block.key, fields, comments)) + elif isinstance(block, String): + signatures.append(("string", block.key, block.value, block.enclosing)) + elif isinstance(block, Preamble): + signatures.append(("preamble", block.value)) + elif isinstance(block, ExplicitComment): + signatures.append(("explicit-comment", block.comment)) + elif isinstance(block, ImplicitComment): + signatures.append(("implicit-comment", block.comment)) + elif isinstance(block, ParsingFailedBlock): + signatures.append(("failed", block.raw, type(block.error).__name__)) + else: + raise TypeError(f"Unsupported block type in compatibility check: {type(block)}") + return tuple(signatures) + + +def _source_is_covered(source: str, library: Library) -> bool: + """Check that block raw spans account for every non-whitespace source token.""" + cursor = 0 + for block in library.blocks: + if block.raw is None: + return False + start = source.find(block.raw, cursor) + if start < 0 or source[cursor:start].strip(): + return False + cursor = start + len(block.raw) + return not source[cursor:].strip() + + +def _failure_diagnostic(block: ParsingFailedBlock) -> CompatibilityDiagnostic: + """Describe a failed block without copying bibliography contents.""" + line = block.start_line + 1 if block.start_line is not None else None + return CompatibilityDiagnostic( + code="parse-failure", + severity="error", + message=( + f"{type(block).__name__} retained source that the parser could not " + "represent as a normal block." + ), + line=line, + ) + + +def _check_source( + source: str, + source_bytes: bytes, + output_encoding: str, +) -> CompatibilityReport: + """Run the default compatibility checks for already decoded source.""" + diagnostics: list[CompatibilityDiagnostic] = [] + source_digest = sha256(source_bytes).hexdigest() + + try: + library = parse_string(source) + except Exception as error: + diagnostics.append( + CompatibilityDiagnostic( + code="parse-error", + severity="error", + message=f"Parsing raised {type(error).__name__}.", + ) + ) + return CompatibilityReport( + source_sha256=source_digest, + source_bytes=len(source_bytes), + source_lines=len(source.splitlines()), + block_count=None, + entry_count=None, + source_covered=None, + parsed_without_failures=False, + semantic_roundtrip=None, + canonical_stable=None, + output_encodable=None, + exact_source_match=None, + diagnostics=tuple(diagnostics), + ) + + source_covered = _source_is_covered(source, library) + if not source_covered: + diagnostics.append( + CompatibilityDiagnostic( + code="source-coverage-gap", + severity="error", + message="Parsed raw spans do not account for all non-whitespace source text.", + ) + ) + + for failed_block in library.failed_blocks: + diagnostics.append(_failure_diagnostic(failed_block)) + parsed_without_failures = not library.failed_blocks + + if not parsed_without_failures: + return CompatibilityReport( + source_sha256=source_digest, + source_bytes=len(source_bytes), + source_lines=len(source.splitlines()), + block_count=len(library.blocks), + entry_count=len(library.entries), + source_covered=source_covered, + parsed_without_failures=False, + semantic_roundtrip=None, + canonical_stable=None, + output_encodable=None, + exact_source_match=None, + diagnostics=tuple(diagnostics), + ) + + try: + canonical = write_string(library) + reparsed = parse_string(canonical) + second_canonical = write_string(reparsed) + except Exception as error: + diagnostics.append( + CompatibilityDiagnostic( + code="roundtrip-error", + severity="error", + message=f"The default parse/write cycle raised {type(error).__name__}.", + ) + ) + return CompatibilityReport( + source_sha256=source_digest, + source_bytes=len(source_bytes), + source_lines=len(source.splitlines()), + block_count=len(library.blocks), + entry_count=len(library.entries), + source_covered=source_covered, + parsed_without_failures=parsed_without_failures, + semantic_roundtrip=None, + canonical_stable=None, + output_encodable=None, + exact_source_match=None, + diagnostics=tuple(diagnostics), + ) + + if not library.failed_blocks and reparsed.failed_blocks: + diagnostics.append( + CompatibilityDiagnostic( + code="reparse-failure", + severity="error", + message="Canonical output introduced one or more parse failures.", + ) + ) + + semantic_roundtrip = _library_signature(reparsed) == _library_signature(library) + if not semantic_roundtrip: + diagnostics.append( + CompatibilityDiagnostic( + code="semantic-roundtrip-mismatch", + severity="error", + message="Default writing and reparsing changed protected bibliography data.", + ) + ) + + canonical_stable = second_canonical == canonical + if not canonical_stable: + diagnostics.append( + CompatibilityDiagnostic( + code="unstable-canonical-output", + severity="error", + message="A second default parse/write cycle changed the output again.", + ) + ) + + try: + canonical_bytes = canonical.encode(output_encoding) + except (LookupError, UnicodeEncodeError) as error: + output_encodable = False + exact_source_match = None + diagnostics.append( + CompatibilityDiagnostic( + code="encode-error", + severity="error", + message=( + f"Canonical output could not be encoded with {output_encoding!r}: " + f"{type(error).__name__}." + ), + ) + ) + else: + output_encodable = True + exact_source_match = canonical_bytes == source_bytes + + return CompatibilityReport( + source_sha256=source_digest, + source_bytes=len(source_bytes), + source_lines=len(source.splitlines()), + block_count=len(library.blocks), + entry_count=len(library.entries), + source_covered=source_covered, + parsed_without_failures=parsed_without_failures, + semantic_roundtrip=semantic_roundtrip, + canonical_stable=canonical_stable, + output_encodable=output_encodable, + exact_source_match=exact_source_match, + diagnostics=tuple(diagnostics), + ) + + +def check_string(source: str) -> CompatibilityReport: + """Check whether a string passes the default non-lossy codec contract.""" + if not isinstance(source, str): + raise TypeError("source must be a string") + return _check_source(source, source.encode("utf-8"), output_encoding="utf-8") + + +def check_file(path: str | Path, encoding: str = "UTF-8") -> CompatibilityReport: + """Read a bibliography file without modifying it and check default compatibility. + + File-system errors are raised to the caller. Decoding failures are returned + as incompatibility diagnostics because they describe the selected codec's + compatibility with the supplied bytes. + """ + source_bytes = Path(path).read_bytes() + try: + source = source_bytes.decode(encoding) + except (LookupError, UnicodeDecodeError) as error: + diagnostic = CompatibilityDiagnostic( + code="decode-error", + severity="error", + message=f"The source could not be decoded with {encoding!r}: {type(error).__name__}.", + ) + return CompatibilityReport( + source_sha256=sha256(source_bytes).hexdigest(), + source_bytes=len(source_bytes), + source_lines=None, + block_count=None, + entry_count=None, + source_covered=None, + parsed_without_failures=None, + semantic_roundtrip=None, + canonical_stable=None, + output_encodable=None, + exact_source_match=None, + diagnostics=(diagnostic,), + ) + return _check_source(source, source_bytes, output_encoding=encoding) diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 00000000..bee53edd --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,156 @@ +"""Public compatibility checks for user-supplied bibliography files.""" + +import os +import tempfile + +import bibtexparser.compatibility as compatibility +from bibtexparser import check_file +from bibtexparser import check_string + + +class TestCompatibilityReport: + """Protect each invariant behind the user-facing compatibility result.""" + + def test_rich_supported_source_passes_all_required_checks(self): + """A rich supported source reports preservation and expected reformatting.""" + source = ( + "% Synthetic header\n" + '@string{journalName="Journal of Tests"}\n' + "@online{record, title={A UTF-8 Überblick}, journal=journalName, " + "date={2024-03/2024-05}}" + ) + + report = check_string(source) + + assert report.compatible + assert report.source_covered + assert report.parsed_without_failures + assert report.semantic_roundtrip + assert report.canonical_stable + assert report.output_encodable + assert report.exact_source_match is False + assert report.entry_count == 1 + assert report.diagnostics == () + + def test_canonical_source_reports_exact_match(self): + """The report distinguishes byte-stable input from allowed layout cleanup.""" + source = "@article{k,\n\ttitle = {Stable}\n}\n" + + report = check_string(source) + + assert report.compatible + assert report.exact_source_match + + def test_explicit_parse_failure_is_incompatible(self): + """Retaining unsupported raw input is safe but not a compatibility pass.""" + report = check_string("@article{broken, title={Retained}") + + assert not report.compatible + assert report.parsed_without_failures is False + assert report.semantic_roundtrip is None + assert report.canonical_stable is None + assert report.output_encodable is None + assert [finding.code for finding in report.diagnostics] == ["parse-failure"] + assert report.diagnostics[0].line == 1 + + def test_unstable_output_cannot_receive_a_compatible_result(self, monkeypatch): + """A writer that changes its output on a second cycle is detected.""" + outputs = iter( + [ + "@article{k,\n\ttitle = {Kept}\n}\n", + "@article{k,\n}\n", + ] + ) + monkeypatch.setattr(compatibility, "write_string", lambda library: next(outputs)) + + report = compatibility.check_string("@article{k, title={Kept}}") + + assert not report.compatible + assert report.semantic_roundtrip is True + assert report.canonical_stable is False + assert [finding.code for finding in report.diagnostics] == ["unstable-canonical-output"] + + def test_semantic_loss_cannot_receive_a_compatible_result(self, monkeypatch): + """A stable writer that consistently drops a field still fails the contract.""" + dropped = "@article{k,\n}\n" + monkeypatch.setattr( + compatibility, + "write_string", + lambda library: dropped, + ) + + report = compatibility.check_string("@article{k, title={Lost}}") + + assert not report.compatible + assert report.semantic_roundtrip is False + assert report.canonical_stable is True + assert [finding.code for finding in report.diagnostics] == ["semantic-roundtrip-mismatch"] + + def test_unrepresented_source_text_is_incompatible(self, monkeypatch): + """Raw-span coverage independently guards against unrepresented top-level text.""" + original_parse = compatibility.parse_string + + def parse_without_comment(source): + return original_parse(source.replace("unrepresented prose", "")) + + monkeypatch.setattr(compatibility, "parse_string", parse_without_comment) + + report = compatibility.check_string("unrepresented prose\n@article{k, title={Kept}}") + + assert not report.compatible + assert report.source_covered is False + assert report.diagnostics[0].code == "source-coverage-gap" + + def test_decode_failure_reports_no_source_contents(self): + """Encoding failures are actionable without exposing bibliography bytes.""" + with tempfile.NamedTemporaryFile(suffix="-private.bib", delete=False) as file: + file.write(b"\xff\xfe") + path = file.name + + try: + report = check_file(path, encoding="utf-8") + serialized = report.to_dict() + + assert not report.compatible + assert serialized["diagnostics"][0]["code"] == "decode-error" + assert "private.bib" not in str(serialized) + assert "\\xff" not in str(serialized) + finally: + os.unlink(path) + + def test_file_identity_compares_bytes_in_the_selected_encoding(self): + """A canonical non-UTF-8 file can still be byte-identical and compatible.""" + source = "@article{k,\n\ttitle = {Müller}\n}\n" + with tempfile.NamedTemporaryFile(delete=False) as file: + file.write(source.encode("latin-1")) + path = file.name + + try: + report = check_file(path, encoding="latin-1") + + assert report.compatible + assert report.output_encodable + assert report.exact_source_match + finally: + os.unlink(path) + + def test_unencodable_canonical_output_is_incompatible(self, monkeypatch): + """A selected output encoding must be able to represent canonical text.""" + monkeypatch.setattr( + compatibility, + "write_string", + lambda library: "@article{k,\n\ttitle = {€}\n}\n", + ) + with tempfile.NamedTemporaryFile(mode="w", encoding="ascii", delete=False) as file: + file.write("@article{k, title={ASCII}}") + path = file.name + + try: + report = check_file(path, encoding="ascii") + + assert not report.compatible + assert report.output_encodable is False + assert report.exact_source_match is None + assert "encode-error" in [finding.code for finding in report.diagnostics] + finally: + os.unlink(path) From 32b5befd21b6929638cf05684affcf664a5f621a Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:23:42 +0200 Subject: [PATCH 2/6] Expose compatibility checks through the CLI * Add text and JSON reports with distinct compatible, incompatible, and I/O exit statuses. * Offer privacy-safe, reviewable issue drafts without submitting or exposing bibliography content. * Report exact source bytes separately from semantic compatibility. --- bibtexparser/__init__.py | 1 + bibtexparser/__main__.py | 5 ++ bibtexparser/cli.py | 105 ++++++++++++++++++++++++++++++++++ bibtexparser/compatibility.py | 55 ++++++++++++++++++ pyproject.toml | 3 + tests/test_cli.py | 91 +++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 bibtexparser/__main__.py create mode 100644 bibtexparser/cli.py create mode 100644 tests/test_cli.py diff --git a/bibtexparser/__init__.py b/bibtexparser/__init__.py index 05e8beae..255a3e54 100644 --- a/bibtexparser/__init__.py +++ b/bibtexparser/__init__.py @@ -3,6 +3,7 @@ import bibtexparser.model from bibtexparser.compatibility import CompatibilityDiagnostic from bibtexparser.compatibility import CompatibilityReport +from bibtexparser.compatibility import build_issue_url from bibtexparser.compatibility import check_file from bibtexparser.compatibility import check_string from bibtexparser.entrypoint import parse_file diff --git a/bibtexparser/__main__.py b/bibtexparser/__main__.py new file mode 100644 index 00000000..b621b407 --- /dev/null +++ b/bibtexparser/__main__.py @@ -0,0 +1,5 @@ +"""Run the bibtexparser command-line interface with ``python -m``.""" + +from .cli import main + +raise SystemExit(main()) diff --git a/bibtexparser/cli.py b/bibtexparser/cli.py new file mode 100644 index 00000000..ede66ee1 --- /dev/null +++ b/bibtexparser/cli.py @@ -0,0 +1,105 @@ +"""Command-line interface for explicit bibliography compatibility checks.""" + +import argparse +import json +import sys +from collections.abc import Sequence +from pathlib import Path + +from . import __version__ +from .compatibility import CompatibilityReport +from .compatibility import build_issue_url +from .compatibility import check_file + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="bibtexparser") + parser.add_argument("--version", action="version", version=__version__) + subparsers = parser.add_subparsers(dest="command", required=True) + + check_parser = subparsers.add_parser( + "check", + help="exercise the default non-lossy contract without modifying the file", + ) + check_parser.add_argument("file", type=Path, help="BibTeX or BibLaTeX file to check") + check_parser.add_argument( + "--encoding", + default="UTF-8", + help="source encoding (default: UTF-8)", + ) + check_parser.add_argument( + "--json", + action="store_true", + help="emit a machine-readable report", + ) + check_parser.add_argument( + "--no-issue-link", + action="store_true", + help="do not include a pre-filled GitHub issue form URL on failure", + ) + return parser + + +def _check_label(value: bool | None) -> str: + if value is None: + return "not run" + return "passed" if value else "failed" + + +def _identity_label(value: bool | None) -> str: + if value is None: + return "not run" + return "unchanged" if value else "would change" + + +def _print_text_report(report: CompatibilityReport, issue_url: str | None) -> None: + status = "COMPATIBLE" if report.compatible else "INCOMPATIBLE" + print(f"{status}: default bibliography codec checks") + print(f" Source coverage: {_check_label(report.source_covered)}") + print(f" Parse failures absent: {_check_label(report.parsed_without_failures)}") + print(f" Semantic round trip: {_check_label(report.semantic_roundtrip)}") + print(f" Canonical output stable: {_check_label(report.canonical_stable)}") + print(f" Output encodable: {_check_label(report.output_encodable)}") + print(f" Exact source bytes: {_identity_label(report.exact_source_match)}") + if report.exact_source_match is False and report.compatible: + print(" Note: default writing would normalize layout, but protected data is stable.") + + for diagnostic in report.diagnostics: + location = f" line {diagnostic.line}" if diagnostic.line is not None else "" + print( + f" {diagnostic.severity.upper()} [{diagnostic.code}]{location}: " + f"{diagnostic.message}" + ) + + if issue_url is not None: + print(" The link below opens a reviewable draft; it does not create an issue.") + print(" It contains no file path, bibliography content, or source snippet.") + print(f" Report issue: {issue_url}") + + +def _run_check(args: argparse.Namespace) -> int: + try: + report = check_file(args.file, encoding=args.encoding) + except OSError as error: + print(f"Could not read {args.file}: {error}", file=sys.stderr) + return 2 + + issue_url = None + if not report.compatible and not args.no_issue_link: + issue_url = build_issue_url(report) + + if args.json: + serialized = report.to_dict() + serialized["issue_url"] = issue_url + print(json.dumps(serialized, indent=2, sort_keys=True)) + else: + _print_text_report(report, issue_url) + return 0 if report.compatible else 1 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the command-line interface and return its process exit status.""" + args = _parser().parse_args(argv) + if args.command == "check": + return _run_check(args) + raise AssertionError(f"Unhandled command: {args.command}") diff --git a/bibtexparser/compatibility.py b/bibtexparser/compatibility.py index 8098a196..1682c366 100644 --- a/bibtexparser/compatibility.py +++ b/bibtexparser/compatibility.py @@ -6,11 +6,15 @@ same implementation cannot independently validate every interpretation choice. """ +import platform from dataclasses import asdict from dataclasses import dataclass from hashlib import sha256 +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version from pathlib import Path from typing import Any +from urllib.parse import urlencode from .entrypoint import parse_string from .entrypoint import write_string @@ -22,6 +26,8 @@ from .model import Preamble from .model import String +ISSUE_TRACKER_URL = "https://github.com/sciunto-org/python-bibtexparser/issues/new" + @dataclass(frozen=True) class CompatibilityDiagnostic: @@ -332,3 +338,52 @@ def check_file(path: str | Path, encoding: str = "UTF-8") -> CompatibilityReport diagnostics=(diagnostic,), ) return _check_source(source, source_bytes, output_encoding=encoding) + + +def build_issue_url( + report: CompatibilityReport, + issue_tracker_url: str = ISSUE_TRACKER_URL, +) -> str: + """Build a reviewable GitHub issue form URL without source text or paths. + + This function only constructs a URL. It performs no network request, opens + no browser, and creates no issue. Bibliography snippets must be added by the + user after checking that they are safe to disclose. + """ + try: + package_version = version("bibtexparser") + except PackageNotFoundError: + package_version = "unknown" + + codes = sorted({diagnostic.code for diagnostic in report.diagnostics}) + code_summary = ", ".join(codes) if codes else "unspecified-check-failure" + checks = report.to_dict()["checks"] + body = "\n".join( + [ + "### Compatibility report", + "", + f"* bibtexparser: {package_version}", + f"* Python: {platform.python_implementation()} {platform.python_version()}", + f"* Platform: {platform.system()} {platform.release()} ({platform.machine()})", + f"* Source SHA-256: `{report.source_sha256}`", + f"* Source size: {report.source_bytes} bytes", + f"* Diagnostic codes: {code_summary}", + f"* Checks: `{checks}`", + "", + "### Reproduction", + "", + "Please add the smallest non-sensitive bibliography example that reproduces the result.", + "", + "### Privacy review", + "", + "No file path, bibliography content, or source snippet was included automatically. " + "Review this draft and anything you add before submitting it.", + ] + ) + query = urlencode( + { + "title": f"Compatibility check failed: {code_summary}", + "body": body, + } + ) + return f"{issue_tracker_url}?{query}" diff --git a/pyproject.toml b/pyproject.toml index ad2db6b5..208dab1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ test = [ "jupyter", # For runnable examples ] +[project.scripts] +bibtexparser = "bibtexparser.cli:main" + [project.urls] Homepage = "https://github.com/sciunto-org/python-bibtexparser" Repository = "https://github.com/sciunto-org/python-bibtexparser" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..1e0faabb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,91 @@ +"""Command-line contracts for read-only compatibility checks.""" + +import json +import os +import tempfile +from pathlib import Path +from urllib.parse import parse_qs +from urllib.parse import urlparse + +from bibtexparser.cli import main + + +class TestCompatibilityCheckCli: + """Protect exit statuses, useful output, and issue-report privacy.""" + + def _source_file(self, source: str) -> Path: + file = tempfile.NamedTemporaryFile( + mode="w", + suffix="-private.bib", + encoding="utf-8", + delete=False, + ) + with file: + file.write(source) + return Path(file.name) + + def test_supported_file_exits_successfully_without_issue_link(self, capsys): + """A passing preflight explains allowed formatting changes without alarm.""" + path = self._source_file("@article{k, title={Supported}}") + try: + status = main(["check", str(path)]) + output = capsys.readouterr().out + finally: + os.unlink(path) + + assert status == 0 + assert output.startswith("COMPATIBLE:") + assert "would normalize layout" in output + assert "github.com" not in output + + def test_failure_offers_reviewable_privacy_safe_issue_draft(self, capsys): + """The issue URL contains diagnostics but no path, snippet, or submission action.""" + source = "@article{private-record, title={Secret title}" + path = self._source_file(source) + try: + status = main(["check", str(path)]) + output = capsys.readouterr().out + finally: + os.unlink(path) + + issue_url = next( + line.removeprefix(" Report issue: ") + for line in output.splitlines() + if line.startswith(" Report issue: ") + ) + query = parse_qs(urlparse(issue_url).query) + issue_body = query["body"][0] + + assert status == 1 + assert "opens a reviewable draft; it does not create an issue" in output + assert "parse-failure" in query["title"][0] + assert "parse-failure" in issue_body + assert str(path) not in issue_body + assert path.name not in issue_body + assert source not in issue_body + assert "Secret title" not in issue_body + assert "No file path, bibliography content, or source snippet" in issue_body + + def test_json_report_can_omit_issue_link(self, capsys): + """Applications receive stable structured output and can suppress the URL.""" + path = self._source_file("@article{private-record, title={Secret title}") + try: + status = main(["check", str(path), "--json", "--no-issue-link"]) + captured = capsys.readouterr() + finally: + os.unlink(path) + + report = json.loads(captured.out) + assert status == 1 + assert report["compatible"] is False + assert report["issue_url"] is None + assert report["diagnostics"][0]["code"] == "parse-failure" + + def test_missing_file_is_a_usage_error(self, capsys): + """I/O failures are distinct from an incompatible bibliography result.""" + status = main(["check", "does-not-exist.bib"]) + captured = capsys.readouterr() + + assert status == 2 + assert captured.out == "" + assert "Could not read" in captured.err From a9ccaf10b8082aadbbba98ef6dd82541a0bab404 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:25:05 +0200 Subject: [PATCH 3/6] Document compatibility preflight * Explain the named checks, exit statuses, and exact-byte distinction. * Describe the privacy-safe issue draft and the limits of same-parser validation. --- README.md | 26 +++++++++++++++++ docs/source/quickstart.rst | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/README.md b/README.md index 02eacb03..5d5cd05b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,32 @@ new_bibtex_string = bibtexparser.write_string(bib_database, These examples really only show the bare minimum. Consult the documentation for a list of available middleware, parsing options and write-formatting options. +## Read-only compatibility preflight + +Before trusting a new or unusual bibliography file, exercise the default codec +contract without modifying the source: + +```bash +python -m bibtexparser check references.bib +# or, after installation: +bibtexparser check references.bib +``` + +The check verifies raw-source coverage, explicit parse failures, semantic +parse/write/reparse preservation, canonical-output stability, and whether the +output remains representable in the selected encoding. It reports exact source +byte identity separately because normal writing may intentionally normalize +layout. Use `--json` for applications. + +An incompatible result includes a reviewable GitHub issue-draft URL by default. +Constructing the URL does not open a browser, make a network request, or create +an issue. No file path, bibliography content, or source snippet is included; +users must review the draft and add only a safe minimal reproduction. Use +`--no-issue-link` to suppress it. + +A passing result means that the file passed the named checks with this version; +it is not a proof that no unanticipated interpretation bug can exist. + ## V2 Architecture and Terminology ![bibtexparserv2](https://user-images.githubusercontent.com/4815944/193734283-f19f94e8-7986-4acf-b1a3-1d215e297224.png) diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ba109b50..463416ad 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -145,6 +145,64 @@ For these types, the block as parsed before the error was detected is available as ``failed_block.ignore_error_block``, which you may use to recover from the error manually (e.g. by fixing and re-adding it to the library) if you choose to do so. +Optional compatibility preflight +-------------------------------- + +For a new or unusual file, the read-only compatibility preflight exercises the +default parse/write contract without changing the source: + +.. code-block:: console + + $ python -m bibtexparser check references.bib + COMPATIBLE: default bibliography codec checks + Source coverage: passed + Parse failures absent: passed + Semantic round trip: passed + Canonical output stable: passed + Output encodable: passed + Exact source bytes: would change + Note: default writing would normalize layout, but protected data is stable. + +After installation, the equivalent command is ``bibtexparser check +references.bib``. Exit status 0 means compatible, 1 means one or more +compatibility invariants failed, and 2 means the file could not be read. Pass +``--json`` for a machine-readable report. + +The same check is available through Python: + +.. code-block:: python + + report = bibtexparser.check_file("references.bib") + if not report.compatible: + for diagnostic in report.diagnostics: + print(diagnostic.code, diagnostic.line, diagnostic.message) + +The preflight verifies: + +* raw block spans cover every non-whitespace part of the source; +* the parser retained no explicit failed blocks; +* writing and reparsing preserve the ordered semantic inventory, including + field order, field enclosures, and supported comment blocks; +* canonical output reaches a fixed point after the first write; and +* canonical output can be represented in the selected source encoding. + +``exact_source_match`` is informational and compares encoded bytes. A false +value is compatible when the only observed effect is permitted canonical +formatting and every required check passes. The report deliberately says which +invariants were checked: using the same parser for the first and second parse +cannot independently prove that every possible input was interpreted correctly. + +The thorough preflight is opt-in because it parses twice and writes twice. +Ordinary parsing continues to log detected block failures and exposes them in +``library.failed_blocks`` without paying that additional cost on every read. + +On incompatibility, the text and JSON CLI reports include a pre-filled GitHub +issue-form URL unless ``--no-issue-link`` is supplied. The URL is only a draft: +the command does not open it, perform a network request, or create an issue. It +contains environment and check metadata plus a source hash, but no file path, +bibliography content, or source snippet. Review the draft and add only a minimal +example that is safe to disclose. + .. _writing_quickstart: Step 3: Exporting with Defaults From a58e75e825b4aacc8a6e41e21eb4f3a1573ea430 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:29:59 +0200 Subject: [PATCH 4/6] Offer opt-in issue reproductions * Extract bounded exact failed blocks without arbitrary truncation. * Keep source-bearing drafts separate from the privacy-safe default link. * Warn that source URLs may persist in logs, browser history, and GitHub drafts. --- bibtexparser/__init__.py | 1 + bibtexparser/cli.py | 68 ++++++++++++++++++-- bibtexparser/compatibility.py | 118 ++++++++++++++++++++++++++-------- tests/test_cli.py | 38 ++++++++++- tests/test_compatibility.py | 39 +++++++++++ 5 files changed, 232 insertions(+), 32 deletions(-) diff --git a/bibtexparser/__init__.py b/bibtexparser/__init__.py index 255a3e54..2154b25b 100644 --- a/bibtexparser/__init__.py +++ b/bibtexparser/__init__.py @@ -6,6 +6,7 @@ from bibtexparser.compatibility import build_issue_url from bibtexparser.compatibility import check_file from bibtexparser.compatibility import check_string +from bibtexparser.compatibility import extract_reproduction_snippet from bibtexparser.entrypoint import parse_file from bibtexparser.entrypoint import parse_string from bibtexparser.entrypoint import write_file diff --git a/bibtexparser/cli.py b/bibtexparser/cli.py index ede66ee1..4454dbe9 100644 --- a/bibtexparser/cli.py +++ b/bibtexparser/cli.py @@ -10,6 +10,7 @@ from .compatibility import CompatibilityReport from .compatibility import build_issue_url from .compatibility import check_file +from .compatibility import extract_reproduction_snippet def _parser() -> argparse.ArgumentParser: @@ -32,11 +33,20 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="emit a machine-readable report", ) - check_parser.add_argument( + issue_link_group = check_parser.add_mutually_exclusive_group() + issue_link_group.add_argument( "--no-issue-link", action="store_true", help="do not include a pre-filled GitHub issue form URL on failure", ) + issue_link_group.add_argument( + "--include-source-in-issue-link", + action="store_true", + help=( + "also generate a sensitive issue URL containing an exact failed block " + "when a bounded reproduction can be extracted" + ), + ) return parser @@ -52,7 +62,12 @@ def _identity_label(value: bool | None) -> str: return "unchanged" if value else "would change" -def _print_text_report(report: CompatibilityReport, issue_url: str | None) -> None: +def _print_text_report( + report: CompatibilityReport, + issue_url: str | None, + reproduction_issue_url: str | None, + reproduction_notice: str | None, +) -> None: status = "COMPATIBLE" if report.compatible else "INCOMPATIBLE" print(f"{status}: default bibliography codec checks") print(f" Source coverage: {_check_label(report.source_covered)}") @@ -74,7 +89,25 @@ def _print_text_report(report: CompatibilityReport, issue_url: str | None) -> No if issue_url is not None: print(" The link below opens a reviewable draft; it does not create an issue.") print(" It contains no file path, bibliography content, or source snippet.") - print(f" Report issue: {issue_url}") + print(f" Privacy-safe issue draft: {issue_url}") + if reproduction_issue_url is None: + print( + " To request a second draft containing an exact failed block, rerun with " + "--include-source-in-issue-link." + ) + print( + " Warning: that URL exposes source in terminal logs, browser history, " + "and the GitHub draft." + ) + if reproduction_notice is not None: + print(f" Source-bearing draft unavailable: {reproduction_notice}") + if reproduction_issue_url is not None: + print( + " WARNING: the next URL contains bibliography source and may persist in " + "terminal logs and browser history." + ) + print(" Review the URL and GitHub draft carefully before submitting anything.") + print(f" Issue draft with reproduction: {reproduction_issue_url}") def _run_check(args: argparse.Namespace) -> int: @@ -85,15 +118,42 @@ def _run_check(args: argparse.Namespace) -> int: return 2 issue_url = None + reproduction_issue_url = None + reproduction_notice = None if not report.compatible and not args.no_issue_link: issue_url = build_issue_url(report) + if args.include_source_in_issue_link: + try: + source = args.file.read_bytes().decode(args.encoding) + reproduction = extract_reproduction_snippet(source) + if reproduction is None: + reproduction_notice = ( + "no exact bounded failed-block reproduction could be extracted; " + "add a reviewed minimal example manually" + ) + else: + reproduction_issue_url = build_issue_url( + report, + reproduction=reproduction, + ) + except (LookupError, UnicodeDecodeError): + reproduction_notice = "the selected encoding could not decode the source" + except ValueError as error: + reproduction_notice = str(error) if args.json: serialized = report.to_dict() serialized["issue_url"] = issue_url + serialized["reproduction_issue_url"] = reproduction_issue_url + serialized["reproduction_notice"] = reproduction_notice print(json.dumps(serialized, indent=2, sort_keys=True)) else: - _print_text_report(report, issue_url) + _print_text_report( + report, + issue_url, + reproduction_issue_url, + reproduction_notice, + ) return 0 if report.compatible else 1 diff --git a/bibtexparser/compatibility.py b/bibtexparser/compatibility.py index 1682c366..75853b51 100644 --- a/bibtexparser/compatibility.py +++ b/bibtexparser/compatibility.py @@ -7,6 +7,7 @@ """ import platform +import re from dataclasses import asdict from dataclasses import dataclass from hashlib import sha256 @@ -27,6 +28,8 @@ from .model import String ISSUE_TRACKER_URL = "https://github.com/sciunto-org/python-bibtexparser/issues/new" +MAX_REPRODUCTION_CHARACTERS = 2_000 +MAX_ISSUE_URL_CHARACTERS = 7_500 @dataclass(frozen=True) @@ -343,12 +346,13 @@ def check_file(path: str | Path, encoding: str = "UTF-8") -> CompatibilityReport def build_issue_url( report: CompatibilityReport, issue_tracker_url: str = ISSUE_TRACKER_URL, + reproduction: str | None = None, ) -> str: - """Build a reviewable GitHub issue form URL without source text or paths. + """Build a reviewable GitHub issue form URL, optionally with source text. This function only constructs a URL. It performs no network request, opens - no browser, and creates no issue. Bibliography snippets must be added by the - user after checking that they are safe to disclose. + no browser, and creates no issue. Passing ``reproduction`` intentionally + embeds that text in the URL; callers must obtain explicit user consent first. """ try: package_version = version("bibtexparser") @@ -358,32 +362,94 @@ def build_issue_url( codes = sorted({diagnostic.code for diagnostic in report.diagnostics}) code_summary = ", ".join(codes) if codes else "unspecified-check-failure" checks = report.to_dict()["checks"] - body = "\n".join( - [ - "### Compatibility report", - "", - f"* bibtexparser: {package_version}", - f"* Python: {platform.python_implementation()} {platform.python_version()}", - f"* Platform: {platform.system()} {platform.release()} ({platform.machine()})", - f"* Source SHA-256: `{report.source_sha256}`", - f"* Source size: {report.source_bytes} bytes", - f"* Diagnostic codes: {code_summary}", - f"* Checks: `{checks}`", - "", - "### Reproduction", - "", - "Please add the smallest non-sensitive bibliography example that reproduces the result.", - "", - "### Privacy review", - "", - "No file path, bibliography content, or source snippet was included automatically. " - "Review this draft and anything you add before submitting it.", - ] - ) + body_lines = [ + "### Compatibility report", + "", + f"* bibtexparser: {package_version}", + f"* Python: {platform.python_implementation()} {platform.python_version()}", + f"* Platform: {platform.system()} {platform.release()} ({platform.machine()})", + f"* Source SHA-256: `{report.source_sha256}`", + f"* Source size: {report.source_bytes} bytes", + f"* Diagnostic codes: {code_summary}", + f"* Checks: `{checks}`", + "", + "### Reproduction", + "", + ] + if reproduction is None: + body_lines.extend( + [ + "Please add the smallest non-sensitive bibliography example that reproduces the result.", + "", + "### Privacy review", + "", + "No file path, bibliography content, or source snippet was included automatically. " + "Review this draft and anything you add before submitting it.", + ] + ) + else: + longest_backtick_run = max( + (len(run) for run in re.findall(r"`+", reproduction)), + default=0, + ) + fence = "`" * max(3, longest_backtick_run + 1) + body_lines.extend( + [ + fence + "bibtex", + reproduction, + fence, + "", + "### Privacy review", + "", + "This draft intentionally includes bibliography source requested by the user. " + "Treat both this URL and the draft as sensitive, review them before submission, " + "and remove anything that should not be public.", + ] + ) + body = "\n".join(body_lines) query = urlencode( { "title": f"Compatibility check failed: {code_summary}", "body": body, } ) - return f"{issue_tracker_url}?{query}" + issue_url = f"{issue_tracker_url}?{query}" + if reproduction is not None and len(issue_url) > MAX_ISSUE_URL_CHARACTERS: + raise ValueError("The reproduction makes the issue URL too long.") + return issue_url + + +def extract_reproduction_snippet( + source: str, + max_characters: int = MAX_REPRODUCTION_CHARACTERS, +) -> str | None: + """Return exact failed block source when it forms a bounded reproduction. + + For duplicate block keys, both the original and duplicate blocks are needed + to reproduce the failure. No arbitrary truncation is performed because a + syntactically incomplete excerpt can be misleading or impossible to parse. + """ + if max_characters < 1: + raise ValueError("max_characters must be positive") + + library = parse_string(source) + candidates: list[tuple[int, str]] = [] + seen: set[tuple[int, str]] = set() + for failed_block in library.failed_blocks: + previous_block = getattr(failed_block, "previous_block", None) + blocks = [previous_block, failed_block] if previous_block is not None else [failed_block] + for block in blocks: + if block is None or block.raw is None: + return None + line = block.start_line if block.start_line is not None else -1 + identity = (line, block.raw) + if identity not in seen: + candidates.append(identity) + seen.add(identity) + + if not candidates: + return None + reproduction = "\n\n".join(raw for _, raw in sorted(candidates)) + if len(reproduction) > max_characters: + return None + return reproduction diff --git a/tests/test_cli.py b/tests/test_cli.py index 1e0faabb..99754aac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -49,9 +49,9 @@ def test_failure_offers_reviewable_privacy_safe_issue_draft(self, capsys): os.unlink(path) issue_url = next( - line.removeprefix(" Report issue: ") + line.removeprefix(" Privacy-safe issue draft: ") for line in output.splitlines() - if line.startswith(" Report issue: ") + if line.startswith(" Privacy-safe issue draft: ") ) query = parse_qs(urlparse(issue_url).query) issue_body = query["body"][0] @@ -65,6 +65,38 @@ def test_failure_offers_reviewable_privacy_safe_issue_draft(self, capsys): assert source not in issue_body assert "Secret title" not in issue_body assert "No file path, bibliography content, or source snippet" in issue_body + assert "--include-source-in-issue-link" in output + assert "terminal logs, browser history" in output + + def test_explicit_source_link_contains_exact_failed_block(self, capsys): + """The sensitive second draft is opt-in, reviewable, and reproducible.""" + source = "@article{private-record, title={Secret title}" + path = self._source_file(source) + try: + status = main(["check", str(path), "--include-source-in-issue-link"]) + output = capsys.readouterr().out + finally: + os.unlink(path) + + safe_url = next( + line.removeprefix(" Privacy-safe issue draft: ") + for line in output.splitlines() + if line.startswith(" Privacy-safe issue draft: ") + ) + reproduction_url = next( + line.removeprefix(" Issue draft with reproduction: ") + for line in output.splitlines() + if line.startswith(" Issue draft with reproduction: ") + ) + safe_body = parse_qs(urlparse(safe_url).query)["body"][0] + reproduction_body = parse_qs(urlparse(reproduction_url).query)["body"][0] + + assert status == 1 + assert source not in safe_body + assert source in reproduction_body + assert "intentionally includes bibliography source" in reproduction_body + assert "WARNING" in output + assert "terminal logs and browser history" in output def test_json_report_can_omit_issue_link(self, capsys): """Applications receive stable structured output and can suppress the URL.""" @@ -79,6 +111,8 @@ def test_json_report_can_omit_issue_link(self, capsys): assert status == 1 assert report["compatible"] is False assert report["issue_url"] is None + assert report["reproduction_issue_url"] is None + assert report["reproduction_notice"] is None assert report["diagnostics"][0]["code"] == "parse-failure" def test_missing_file_is_a_usage_error(self, capsys): diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index bee53edd..115d46fa 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -2,10 +2,14 @@ import os import tempfile +from urllib.parse import parse_qs +from urllib.parse import urlparse import bibtexparser.compatibility as compatibility +from bibtexparser import build_issue_url from bibtexparser import check_file from bibtexparser import check_string +from bibtexparser import extract_reproduction_snippet class TestCompatibilityReport: @@ -154,3 +158,38 @@ def test_unencodable_canonical_output_is_incompatible(self, monkeypatch): assert "encode-error" in [finding.code for finding in report.diagnostics] finally: os.unlink(path) + + +class TestIssueReproduction: + """Protect explicit source disclosure and useful reproduction extraction.""" + + def test_failed_block_is_returned_without_truncation(self): + """A source-bearing draft uses the exact failed block seen by the parser.""" + source = "@article{private-record, title={Secret title}" + + assert extract_reproduction_snippet(source) == source + + def test_duplicate_key_reproduction_contains_both_blocks(self): + """A duplicate-key report includes the original needed to trigger the failure.""" + first = "@article{same, title={First}}" + duplicate = "@book{same, title={Second}}" + source = f"{first}\n{duplicate}" + + assert extract_reproduction_snippet(source) == f"{first}\n\n{duplicate}" + + def test_oversized_reproduction_is_refused_instead_of_truncated(self): + """An incomplete automatic excerpt must not masquerade as a reproduction.""" + source = "@article{private-record, title={Secret title}" + + assert extract_reproduction_snippet(source, max_characters=10) is None + + def test_source_bearing_issue_url_uses_a_safe_markdown_fence(self): + """Backticks in source cannot escape the reproduction code block.""" + source = "@article{private-record, note={```}" + report = check_string(source) + + issue_url = build_issue_url(report, reproduction=source) + issue_body = parse_qs(urlparse(issue_url).query)["body"][0] + + assert "````bibtex\n" + source + "\n````" in issue_body + assert "intentionally includes bibliography source" in issue_body From 2033a37238ac16b6f3b7f0bd8d933128dffa2d31 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:30:35 +0200 Subject: [PATCH 5/6] Document source-bearing issue drafts * Explain the explicit disclosure option and its privacy risks. * Describe bounded failed-block and duplicate-key reproductions without truncation. --- README.md | 18 +++++++++++++----- docs/source/quickstart.rst | 29 +++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5d5cd05b..42c58213 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,19 @@ output remains representable in the selected encoding. It reports exact source byte identity separately because normal writing may intentionally normalize layout. Use `--json` for applications. -An incompatible result includes a reviewable GitHub issue-draft URL by default. -Constructing the URL does not open a browser, make a network request, or create -an issue. No file path, bibliography content, or source snippet is included; -users must review the draft and add only a safe minimal reproduction. Use -`--no-issue-link` to suppress it. +An incompatible result includes a reviewable, privacy-safe GitHub issue-draft +URL by default. Constructing the URL does not open a browser, make a network +request, or create an issue. No file path, bibliography content, or source +snippet is included. + +The same report explains how to rerun with +`--include-source-in-issue-link`. That explicit opt-in retains the privacy-safe +draft and adds a second draft containing an exact failed block when the checker +can extract a bounded reproduction. For duplicate keys, it includes both +relevant blocks. It never truncates a block into a potentially invalid example. +The second URL exposes bibliography source in terminal logs, browser history, +and the GitHub draft, so review all three before submission. Use +`--no-issue-link` to suppress issue links entirely. A passing result means that the file passed the named checks with this version; it is not a proof that no unanticipated interpretation bug can exist. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 463416ad..981cd6d8 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -196,12 +196,29 @@ The thorough preflight is opt-in because it parses twice and writes twice. Ordinary parsing continues to log detected block failures and exposes them in ``library.failed_blocks`` without paying that additional cost on every read. -On incompatibility, the text and JSON CLI reports include a pre-filled GitHub -issue-form URL unless ``--no-issue-link`` is supplied. The URL is only a draft: -the command does not open it, perform a network request, or create an issue. It -contains environment and check metadata plus a source hash, but no file path, -bibliography content, or source snippet. Review the draft and add only a minimal -example that is safe to disclose. +On incompatibility, the text and JSON CLI reports include a pre-filled, +privacy-safe GitHub issue-form URL unless ``--no-issue-link`` is supplied. The +URL is only a draft: the command does not open it, perform a network request, or +create an issue. It contains environment and check metadata plus a source hash, +but no file path, bibliography content, or source snippet. + +The text report also points to the explicit source-disclosure option: + +.. code-block:: console + + $ python -m bibtexparser check references.bib --include-source-in-issue-link + +That command keeps the privacy-safe draft and adds a second draft containing an +exact failed block when the checker can extract a bounded reproduction. A +duplicate-key reproduction includes both relevant blocks. The checker does not +truncate an oversized block, because a partial block could be invalid or fail +to reproduce the problem; in that case, add a reviewed minimal example +manually. + +The source-bearing URL exposes bibliography content in terminal logs, browser +history, and the GitHub draft even if the issue is never submitted. The command +therefore requires explicit opt-in and prints a warning. Review the URL and the +draft carefully before submitting anything. .. _writing_quickstart: From 11bd2b807c566e85972a749fbd79beda33927f8f Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Thu, 16 Jul 2026 17:34:47 +0200 Subject: [PATCH 6/6] Reject stale issue reproductions * Verify reread bytes against the checked source hash before embedding a failed block. * Refuse source-bearing drafts when the file changes or cannot be reread. --- bibtexparser/cli.py | 27 ++++++++++++++++++--------- tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/bibtexparser/cli.py b/bibtexparser/cli.py index 4454dbe9..69374dcc 100644 --- a/bibtexparser/cli.py +++ b/bibtexparser/cli.py @@ -4,6 +4,7 @@ import json import sys from collections.abc import Sequence +from hashlib import sha256 from pathlib import Path from . import __version__ @@ -124,18 +125,26 @@ def _run_check(args: argparse.Namespace) -> int: issue_url = build_issue_url(report) if args.include_source_in_issue_link: try: - source = args.file.read_bytes().decode(args.encoding) - reproduction = extract_reproduction_snippet(source) - if reproduction is None: + source_bytes = args.file.read_bytes() + if sha256(source_bytes).hexdigest() != report.source_sha256: reproduction_notice = ( - "no exact bounded failed-block reproduction could be extracted; " - "add a reviewed minimal example manually" + "the file changed after the compatibility check; rerun the command" ) else: - reproduction_issue_url = build_issue_url( - report, - reproduction=reproduction, - ) + source = source_bytes.decode(args.encoding) + reproduction = extract_reproduction_snippet(source) + if reproduction is None: + reproduction_notice = ( + "no exact bounded failed-block reproduction could be " + "extracted; add a reviewed minimal example manually" + ) + else: + reproduction_issue_url = build_issue_url( + report, + reproduction=reproduction, + ) + except OSError: + reproduction_notice = "the file could not be reread for a source-bearing draft" except (LookupError, UnicodeDecodeError): reproduction_notice = "the selected encoding could not decode the source" except ValueError as error: diff --git a/tests/test_cli.py b/tests/test_cli.py index 99754aac..8e64c5fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -98,6 +98,34 @@ def test_explicit_source_link_contains_exact_failed_block(self, capsys): assert "WARNING" in output assert "terminal logs and browser history" in output + def test_changed_file_cannot_be_attached_to_an_outdated_report(self, capsys, monkeypatch): + """The reproduction and privacy-safe report must describe identical bytes.""" + original_source = b"@article{private-record, title={Original}" + changed_source = b"@article{private-record, title={Changed}" + path = self._source_file(original_source.decode()) + original_read_bytes = Path.read_bytes + reads = 0 + + def changing_read_bytes(candidate): + nonlocal reads + if candidate == path: + reads += 1 + return original_source if reads == 1 else changed_source + return original_read_bytes(candidate) + + monkeypatch.setattr(Path, "read_bytes", changing_read_bytes) + try: + status = main(["check", str(path), "--include-source-in-issue-link"]) + output = capsys.readouterr().out + finally: + os.unlink(path) + + assert status == 1 + assert "file changed after the compatibility check" in output + assert "Issue draft with reproduction:" not in output + assert original_source.decode() not in output + assert changed_source.decode() not in output + def test_json_report_can_omit_issue_link(self, capsys): """Applications receive stable structured output and can suppress the URL.""" path = self._source_file("@article{private-record, title={Secret title}")