Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions MethodenAnalyser3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<p align="center">
<a href="https://github.com/dev-bricks/MethodenAnalyser/releases/tag/v3.0.2"><img src="https://img.shields.io/badge/Version-3.0.2-blue?style=for-the-badge" alt="Version 3.0.2"></a>
<a href="https://github.com/dev-bricks/MethodenAnalyser/actions/workflows/tests.yml"><img src="https://img.shields.io/badge/CI-Passing-success?style=for-the-badge&logo=githubactions&logoColor=white" alt="CI Status"></a>
<a href="#development--testing"><img src="https://img.shields.io/badge/Tests-215%20Passed-success?style=for-the-badge&logo=pytest&logoColor=white" alt="Tests 100% Green"></a>
<a href="#development--testing"><img src="https://img.shields.io/badge/Tests-225%20Passed-success?style=for-the-badge&logo=pytest&logoColor=white" alt="Tests 100% Green"></a>
<img src="https://img.shields.io/badge/Python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-yellow?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10-3.13">
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=for-the-badge" alt="Cross-Platform">
<img src="https://img.shields.io/badge/GUI-Tkinter%20%2B%20CLI%20%2B%20PWA-orange?style=for-the-badge" alt="GUI Desktop + CLI + PWA">
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<p align="center">
<a href="https://github.com/dev-bricks/MethodenAnalyser/releases/tag/v3.0.2"><img src="https://img.shields.io/badge/Version-3.0.2-blue?style=for-the-badge" alt="Version 3.0.2"></a>
<a href="https://github.com/dev-bricks/MethodenAnalyser/actions/workflows/tests.yml"><img src="https://img.shields.io/badge/CI-Bestanden-success?style=for-the-badge&logo=githubactions&logoColor=white" alt="CI-Status"></a>
<a href="#entwicklung--tests"><img src="https://img.shields.io/badge/Tests-215%20Bestanden-success?style=for-the-badge&logo=pytest&logoColor=white" alt="Tests 100% Grün"></a>
<a href="#entwicklung--tests"><img src="https://img.shields.io/badge/Tests-225%20Bestanden-success?style=for-the-badge&logo=pytest&logoColor=white" alt="Tests 100% Grün"></a>
<img src="https://img.shields.io/badge/Python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-yellow?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10-3.13">
<img src="https://img.shields.io/badge/Plattform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey?style=for-the-badge" alt="Plattformübergreifend">
<img src="https://img.shields.io/badge/GUI-Tkinter%20%2B%20CLI%20%2B%20PWA-orange?style=for-the-badge" alt="GUI Desktop + CLI + PWA">
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
125 changes: 125 additions & 0 deletions tests/test_json_atomic_write.py
Original file line number Diff line number Diff line change
@@ -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)
Loading