From ca5abc5a5228bc95ad4cc346b702fe36abb4d0e5 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 30 Sep 2026 22:44:51 +0200 Subject: [PATCH] fix: publish JSON reports atomically --- MethodenAnalyser3.py | 33 +++++++-- README.md | 4 +- README_de.md | 4 +- llms.txt | 1 + tests/test_json_atomic_write.py | 125 ++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 tests/test_json_atomic_write.py diff --git a/MethodenAnalyser3.py b/MethodenAnalyser3.py index 5e8d18a..7ee1b1d 100644 --- a/MethodenAnalyser3.py +++ b/MethodenAnalyser3.py @@ -2078,14 +2078,33 @@ def _safe_relpath(p: str) -> str: def write_json_report(report: dict[str, Any], output_path: str) -> str: - """Schreibt einen JSON-Report und gibt den absoluten Pfad zurück.""" + """Veröffentlicht einen vollständigen JSON-Report per atomarem Dateitausch.""" + data = (json.dumps(report, indent=2, ensure_ascii=False) + "\n").encode("utf-8") target = os.path.abspath(output_path) - target_dir = os.path.dirname(target) - if target_dir: - os.makedirs(target_dir, exist_ok=True) - with open(target, "w", encoding="utf-8") as f: - json.dump(report, f, indent=2, ensure_ascii=False) - f.write("\n") + destination = pathlib.Path(target).resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + mode = stat.S_IMODE(destination.stat().st_mode) if destination.exists() else None + if mode is not None and not mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH): + raise PermissionError("JSON report target is read-only") + staged = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", dir=destination.parent, + prefix=f".{destination.name}.json-", delete=False, + ) as handle: + staged = pathlib.Path(handle.name) + if handle.write(data) != len(data): + raise OSError("Incomplete JSON report write") + handle.flush() + os.fsync(handle.fileno()) + if mode is not None: + staged.chmod(mode) + os.replace(staged, destination) + staged = None + finally: + if staged is not None: + staged.chmod(stat.S_IRUSR | stat.S_IWUSR) + staged.unlink(missing_ok=True) return target diff --git a/README.md b/README.md index 093de0b..477407b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version 3.0.2 CI Status - Tests 100% Green + Tests 100% Green Python 3.10-3.13 Cross-Platform GUI Desktop + CLI + PWA @@ -230,7 +230,7 @@ type path\to\file.py | python MethodenAnalyser3.py --stdin --json-output snippet python MethodenAnalyser3.py --lang en --file path/to/file.py ``` -The `--json-output` flag exports a machine-readable report named `methodenanalyser-report-v1.json` (or a custom name if specified). Its structure is documented in [EXPORTFORMAT.md](EXPORTFORMAT.md). Export targets that refer to an analyzed source file, including symbolic links and hard links, are rejected with exit code 1 before writing. This also protects project files that failed analysis. +The `--json-output` flag exports a machine-readable report named `methodenanalyser-report-v1.json` (or a custom name if specified). Its structure is documented in [EXPORTFORMAT.md](EXPORTFORMAT.md). Export targets that refer to an analyzed source file, including symbolic links and hard links, are rejected with exit code 1 before writing. This also protects project files that failed analysis. JSON is fully serialized and flushed to a temporary file before atomic replacement: serialization or write failures preserve an existing report. Read-only targets are rejected; existing file modes and symbolic links to report destinations are preserved. The local `POST /api/analyze` helper accepts `source_kind` `snippet`, `file`, and `zip`. A `project` POST is intentionally rejected; project reports are diff --git a/README_de.md b/README_de.md index 767b25d..7e1e000 100644 --- a/README_de.md +++ b/README_de.md @@ -5,7 +5,7 @@

Version 3.0.2 CI-Status - Tests 100% Grün + Tests 100% Grün Python 3.10-3.13 Plattformübergreifend GUI Desktop + CLI + PWA @@ -230,7 +230,7 @@ type pfad\zur\datei.py | python MethodenAnalyser3.py --stdin --json-output snipp python MethodenAnalyser3.py --lang de --file pfad/zur/datei.py ``` -Das Flag `--json-output` exportiert einen maschinenlesbaren Bericht namens `methodenanalyser-report-v1.json` (oder unter dem übergebenen Dateinamen). Das Schema ist in [EXPORTFORMAT.md](EXPORTFORMAT.md) beschrieben. Ausgabeziele, die auf eine analysierte Quelldatei zeigen, werden vor dem Schreiben mit Exit-Code 1 abgelehnt; das gilt auch für symbolische Links und Hard Links sowie für Projektdateien mit Analysefehlern. +Das Flag `--json-output` exportiert einen maschinenlesbaren Bericht namens `methodenanalyser-report-v1.json` (oder unter dem übergebenen Dateinamen). Das Schema ist in [EXPORTFORMAT.md](EXPORTFORMAT.md) beschrieben. Ausgabeziele, die auf eine analysierte Quelldatei zeigen, werden vor dem Schreiben mit Exit-Code 1 abgelehnt; das gilt auch für symbolische Links und Hard Links sowie für Projektdateien mit Analysefehlern. JSON wird vollständig serialisiert und in eine temporäre Datei geschrieben, bevor diese das Ziel atomar ersetzt. Bei Serialisierungs- oder Schreibfehlern bleibt ein vorhandener Bericht erhalten. Schreibgeschützte Ziele werden abgelehnt; bestehende Dateimodi und symbolische Links auf Berichtsziele bleiben erhalten. Der lokale `POST /api/analyze`-Endpunkt akzeptiert `source_kind` `snippet`, `file` und `zip`. Ein `project`-POST wird bewusst abgewiesen; Projektberichte entstehen diff --git a/llms.txt b/llms.txt index b3af981..1c63700 100644 --- a/llms.txt +++ b/llms.txt @@ -23,6 +23,7 @@ MethodenAnalyser analyzes Python files and project folders locally with Python A - CLI file analysis: `python MethodenAnalyser3.py --file path/to/file.py`. - CLI project analysis: `python MethodenAnalyser3.py --project path/to/project`. - JSON export: add `--json-output` or pass a target JSON filename. +- JSON report publication serializes first, writes and fsyncs a same-directory temporary file, then replaces the destination atomically. Failures before replacement preserve previous bytes; read-only destinations are rejected. Existing report symlinks remain links; replacement changes the destination inode and does not update other hard-link names. This is not a concurrent-writer lock or a directory-fsync crash-durability guarantee. - CLI JSON export rejects targets that alias analyzed sources (including hard links, symbolic links, and project files with analysis errors); exit code 1, no source write. This check does not lock against concurrent path changes. - Local web helper: `python webapp/server.py` or `START_WEBAPP.bat`; its API accepts `snippet`, `file` and `zip`, while `project` is import-only. - Multi-language support: `--lang` parameter supporting `de`, `en`, `es`, `zh`, `ja`, and `ru`. diff --git a/tests/test_json_atomic_write.py b/tests/test_json_atomic_write.py new file mode 100644 index 0000000..3b40c16 --- /dev/null +++ b/tests/test_json_atomic_write.py @@ -0,0 +1,125 @@ +import json +import stat + +import pytest + +import MethodenAnalyser3 as app + + +def test_serialization_error_preserves_previous_report(tmp_path): + target = tmp_path / "report.json" + target.write_bytes(b'{"previous": true}\n') + with pytest.raises(TypeError): + app.write_json_report({"first": "valid", "later": object()}, str(target)) + assert target.read_bytes() == b'{"previous": true}\n' + assert sorted(p.name for p in tmp_path.iterdir()) == ["report.json"] + + +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("failure", ["fsync", "replace"]) +def test_write_failure_preserves_target_and_removes_temp(tmp_path, monkeypatch, existing, failure): + target = tmp_path / "report.json" + if existing: + target.write_bytes(b"previous report") + + def fail(*_args): + raise OSError("injected write failure") + + monkeypatch.setattr(app.os, failure, fail) + with pytest.raises(OSError, match="injected"): + app.write_json_report({"new": "Grüße"}, str(target)) + assert target.exists() == existing + if existing: + assert target.read_bytes() == b"previous report" + assert sorted(p.name for p in tmp_path.iterdir()) == (["report.json"] if existing else []) + + +def test_success_publishes_complete_utf8_report(tmp_path, monkeypatch): + target = tmp_path / "nested" / "report.json" + actual_replace = app.os.replace + observed = [] + + def replace(staged, destination): + staged_path = app.pathlib.Path(staged) + assert staged_path.parent == target.parent + assert json.loads(staged_path.read_text(encoding="utf-8")) == {"text": "Grüße"} + assert not target.exists() + observed.append(True) + actual_replace(staged, destination) + + monkeypatch.setattr(app.os, "replace", replace) + assert app.write_json_report({"text": "Grüße"}, str(target)) == str(target) + assert observed == [True] + assert target.read_bytes().endswith(b"\n") + assert json.loads(target.read_text(encoding="utf-8")) == {"text": "Grüße"} + assert sorted(p.name for p in target.parent.iterdir()) == ["report.json"] + + +@pytest.mark.parametrize("existing", [False, True]) +def test_short_write_never_publishes_partial_json(tmp_path, monkeypatch, existing): + target = tmp_path / "report.json" + if existing: + target.write_bytes(b"previous report") + actual_temp = app.tempfile.NamedTemporaryFile + + class ShortWriter: + def __init__(self, *args, **kwargs): + self.handle = actual_temp(*args, **kwargs) + + def __enter__(self): + self.handle.__enter__() + return self + + def __exit__(self, *args): + return self.handle.__exit__(*args) + + @property + def name(self): + return self.handle.name + + def write(self, data): + return self.handle.write(data[:4]) + + monkeypatch.setattr(app.tempfile, "NamedTemporaryFile", ShortWriter) + with pytest.raises(OSError, match="Incomplete JSON"): + app.write_json_report({"text": "Grüße"}, str(target)) + assert target.exists() == existing + if existing: + assert target.read_bytes() == b"previous report" + assert sorted(p.name for p in tmp_path.iterdir()) == (["report.json"] if existing else []) + + +def test_replace_preserves_existing_file_mode(tmp_path): + target = tmp_path / "report.json" + target.write_bytes(b"old") + target.chmod(0o640) + mode = stat.S_IMODE(target.stat().st_mode) + app.write_json_report({"new": True}, str(target)) + assert stat.S_IMODE(target.stat().st_mode) == mode + assert json.loads(target.read_text(encoding="utf-8")) == {"new": True} + + +def test_report_symlink_is_preserved(tmp_path): + destination = tmp_path / "destination.json" + destination.write_bytes(b"old") + link = tmp_path / "report.json" + try: + link.symlink_to(destination) + except OSError as exc: + pytest.skip(f"symlink unavailable: {exc}") + assert app.write_json_report({"new": True}, str(link)) == str(link) + assert link.is_symlink() + assert json.loads(destination.read_text(encoding="utf-8")) == {"new": True} + + +def test_read_only_report_is_preserved(tmp_path): + target = tmp_path / "report.json" + target.write_bytes(b"previous report") + target.chmod(stat.S_IRUSR) + try: + with pytest.raises(PermissionError): + app.write_json_report({"new": True}, str(target)) + assert target.read_bytes() == b"previous report" + assert sorted(p.name for p in tmp_path.iterdir()) == ["report.json"] + finally: + target.chmod(stat.S_IRUSR | stat.S_IWUSR)