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 @@

Version 3.0.2 CI Status - Tests 100% Green + Tests 100% Green Python 3.10-3.13 Cross-Platform GUI Desktop + CLI + PWA @@ -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 @@

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 @@ -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()