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
49 changes: 33 additions & 16 deletions MethodenAnalyser3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)}")
Expand Down
8 changes: 7 additions & 1 deletion 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-225%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-231%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 @@ -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
Expand Down
9 changes: 8 additions & 1 deletion 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-225%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-231%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 @@ -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
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.
- 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.
Expand Down
92 changes: 92 additions & 0 deletions tests/test_gui_report_publication.py
Original file line number Diff line number Diff line change
@@ -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()
Loading