diff --git a/MethodenAnalyser3.py b/MethodenAnalyser3.py index 7ee1b1d..ad5a2c2 100644 --- a/MethodenAnalyser3.py +++ b/MethodenAnalyser3.py @@ -1382,7 +1382,7 @@ def create_safe_filename(original_path: str, suffix: str) -> str: # Wenn Datei existiert, nummeriere counter = 1 - while os.path.exists(export_path): + while os.path.lexists(export_path): export_path = f"{base_path}_{counter}{suffix}" counter += 1 @@ -1442,12 +1442,18 @@ def run_analysis(output_widget: scrolledtext.ScrolledText, status_widget: tk.Lab # Export mit Bestätigung try: - export_path = create_safe_filename(path, "_analysis.txt") - - with open(export_path, "w", encoding="utf-8") as f: - f.write(f"{_t('gui_analyzed_file')}: {path}\n") - f.write(f"{_t('gui_date')}: {datetime.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M:%S')}\n\n") - f.write(generate_report(result)) + text = ( + f"{_t('gui_analyzed_file')}: {path}\n" + f"{_t('gui_date')}: {datetime.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + generate_report(result) + ) + while True: + export_path = create_safe_filename(path, "_analysis.txt") + try: + _write_report_bytes(text.encode("utf-8"), export_path, exclusive=True, label="Text") + except FileExistsError: + continue + break output_widget.insert(tk.END, f"\n{_t('gui_report_saved')}: {export_path}") if status_widget is not None: @@ -2080,27 +2086,38 @@ def _safe_relpath(p: str) -> str: def write_json_report(report: dict[str, Any], output_path: str) -> str: """Veröffentlicht einen vollständigen JSON-Report per atomarem Dateitausch.""" data = (json.dumps(report, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + return _write_report_bytes(data, output_path) + + +def _write_report_bytes( + data: bytes, output_path: str, *, exclusive: bool = False, label: str = "JSON", +) -> str: + """Write a complete report; publish exclusively or replace atomically.""" target = os.path.abspath(output_path) - destination = pathlib.Path(target).resolve() + raw_path = pathlib.Path(target) + destination = raw_path.parent.resolve() / raw_path.name if exclusive else raw_path.resolve() destination.parent.mkdir(parents=True, exist_ok=True) - mode = stat.S_IMODE(destination.stat().st_mode) if destination.exists() else None + mode = stat.S_IMODE(destination.stat().st_mode) if not exclusive and 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") + raise PermissionError(f"{label} report target is read-only") staged = None try: with tempfile.NamedTemporaryFile( mode="wb", dir=destination.parent, - prefix=f".{destination.name}.json-", delete=False, + prefix=f".{destination.name}.report-", delete=False, ) as handle: staged = pathlib.Path(handle.name) if handle.write(data) != len(data): - raise OSError("Incomplete JSON report write") + raise OSError(f"Incomplete {label} report write") handle.flush() os.fsync(handle.fileno()) if mode is not None: staged.chmod(mode) - os.replace(staged, destination) - staged = None + if exclusive: + os.link(staged, destination) + else: + os.replace(staged, destination) + staged = None finally: if staged is not None: staged.chmod(stat.S_IRUSR | stat.S_IWUSR) @@ -2199,8 +2216,8 @@ def progress_cb(cur, tot, fp): output_widget.insert(tk.END, generate_project_report(result)) export_path = os.path.join(folder_path, "project_analysis.txt") - with open(export_path, "w", encoding="utf-8") as f: - f.write(generate_project_report(result, project_name=_extract_project_display_name(folder_path))) + text = generate_project_report(result, project_name=_extract_project_display_name(folder_path)) + _write_report_bytes(text.encode("utf-8"), export_path, label="Text") output_widget.insert(tk.END, f"\n{_t('gui_saved')}: {export_path}") if status_widget is not None: status_widget.config(text=f"{_t('status_analysis_done')} {os.path.basename(folder_path)}") diff --git a/README.md b/README.md index 477407b..a113001 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
-
+
@@ -349,6 +349,12 @@ See the full [SECURITY.md](SECURITY.md) for detailed policies in English and Ger
## Development & Testing
+GUI text reports use the same flushed, atomic publication as JSON reports.
+The project report replaces its previous version only when complete. Individual
+file reports are published exclusively under a free numbered name, preserving
+files created concurrently. Exclusive publication requires hard-link support;
+an unsupported filesystem produces an export error instead of overwriting.
+
```bash
# Verify Python syntax and AST compilation
python -m py_compile MethodenAnalyser3.py manage_translations.py translator.py webapp/server.py
diff --git a/README_de.md b/README_de.md
index 7e1e000..589059a 100644
--- a/README_de.md
+++ b/README_de.md
@@ -5,7 +5,7 @@
-
+
@@ -352,6 +352,13 @@ Vollständige Richtlinie siehe [SECURITY.md](SECURITY.md).
## Entwicklung & Tests
+Textberichte der GUI verwenden dieselbe vollständig geschriebene, atomare
+Veröffentlichung wie JSON-Berichte. Der Projektbericht ersetzt seinen Vorläufer
+erst nach vollständigem Schreiben. Einzelberichte werden exklusiv unter einem
+freien, gegebenenfalls nummerierten Namen angelegt; zwischenzeitlich entstandene
+Dateien bleiben erhalten. Dafür muss das Dateisystem Hard Links unterstützen;
+andernfalls meldet die GUI einen Exportfehler.
+
```bash
# Syntax- und Bytecode-Prüfung
python -m py_compile MethodenAnalyser3.py manage_translations.py translator.py webapp/server.py
diff --git a/llms.txt b/llms.txt
index 1c63700..1d82540 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.
+- GUI text reports share atomic report staging. Project reports replace complete previous outputs; file reports publish exclusively via a same-directory hard link and retry numbered names on collisions. Existing files, including dangling symlinks, are not overwritten. Hard-link support is required for exclusive publication; unsupported filesystems fail with a GUI export error.
- 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.
diff --git a/tests/test_gui_report_publication.py b/tests/test_gui_report_publication.py
new file mode 100644
index 0000000..472184f
--- /dev/null
+++ b/tests/test_gui_report_publication.py
@@ -0,0 +1,92 @@
+from unittest.mock import Mock
+
+import pytest
+
+import MethodenAnalyser3 as app
+
+
+def prepare_file(monkeypatch, tmp_path):
+ source = tmp_path / "source.py"
+ source.write_text("print('Grüße')\n", encoding="utf-8")
+ monkeypatch.setattr(app.filedialog, "askopenfilename", lambda **_kw: str(source))
+ monkeypatch.setattr(app, "messagebox", Mock())
+ return source
+
+
+def test_formatting_failure_does_not_create_partial_file_report(tmp_path, monkeypatch):
+ prepare_file(monkeypatch, tmp_path)
+ monkeypatch.setattr(app, "generate_report", Mock(side_effect=["display", RuntimeError("format failure")]))
+ app.run_analysis(Mock(), Mock())
+ assert not (tmp_path / "source_analysis.txt").exists()
+ app.messagebox.showwarning.assert_called_once()
+
+
+def test_project_replace_error_preserves_previous_report(tmp_path, monkeypatch):
+ report = tmp_path / "project_analysis.txt"
+ report.write_bytes(b"previous report")
+ monkeypatch.setattr(app.filedialog, "askdirectory", lambda **_kw: str(tmp_path))
+ monkeypatch.setattr(app, "messagebox", Mock())
+
+ def fail(*_args):
+ raise OSError("replace failure")
+
+ monkeypatch.setattr(app.os, "replace", fail)
+ app.run_project_analysis(Mock(), Mock())
+ assert report.read_bytes() == b"previous report"
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["project_analysis.txt"]
+ app.messagebox.showerror.assert_called_once()
+
+
+def test_concurrent_file_report_is_preserved_and_export_gets_new_name(tmp_path, monkeypatch):
+ prepare_file(monkeypatch, tmp_path)
+ monkeypatch.setattr(app, "generate_report", lambda _result: "EXPORT Grüße")
+ first = tmp_path / "source_analysis.txt"
+ actual_link = app.os.link
+ actual_choose = app.create_safe_filename
+ attempts = []
+
+ def choose(*args):
+ chosen = actual_choose(*args)
+ if not first.exists():
+ first.write_bytes(b"concurrent report")
+ return chosen
+
+ def publish(staged, destination):
+ attempts.append(destination)
+ return actual_link(staged, destination)
+
+ monkeypatch.setattr(app, "create_safe_filename", choose)
+ monkeypatch.setattr(app.os, "link", publish)
+ app.run_analysis(Mock(), Mock())
+ assert first.read_bytes() == b"concurrent report"
+ assert "EXPORT Grüße" in (tmp_path / "source_1_analysis.txt").read_text(encoding="utf-8")
+ assert len(attempts) == 2
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["source.py", "source_1_analysis.txt", "source_analysis.txt"]
+ app.messagebox.showwarning.assert_not_called()
+
+
+@pytest.mark.parametrize("failure", ["fsync", "link"])
+def test_file_report_publication_failure_leaves_no_partial_output(tmp_path, monkeypatch, failure):
+ source = prepare_file(monkeypatch, tmp_path)
+ original = source.read_bytes()
+
+ def fail(*_args):
+ raise OSError("publication failure")
+
+ monkeypatch.setattr(app.os, failure, fail)
+ app.run_analysis(Mock(), Mock())
+ assert source.read_bytes() == original
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["source.py"]
+ app.messagebox.showwarning.assert_called_once()
+
+
+def test_project_report_success_replaces_previous_content(tmp_path, monkeypatch):
+ report = tmp_path / "project_analysis.txt"
+ report.write_bytes(b"previous report")
+ monkeypatch.setattr(app.filedialog, "askdirectory", lambda **_kw: str(tmp_path))
+ monkeypatch.setattr(app, "messagebox", Mock())
+ monkeypatch.setattr(app, "generate_project_report", lambda *_args, **_kwargs: "REPORT Grüße")
+ app.run_project_analysis(Mock(), Mock())
+ assert report.read_text(encoding="utf-8") == "REPORT Grüße"
+ assert sorted(p.name for p in tmp_path.iterdir()) == ["project_analysis.txt"]
+ app.messagebox.showerror.assert_not_called()