fix: --report darf keine erzeugte Datei ersetzen, Schreibfehler mit Exit 1 (QA2-07)

--report OUTPUT/manifest.json ersetzte das Manifest stillschweigend durch
den Bericht. Ein nicht beschreibbares --report-Ziel endete nach bereits
geschriebener Ausgabe mit Exit 4 ("Interner Fehler"). Jetzt: Abbruch mit
Exit 1 vor dem Schreiben bzw. verständliche Meldung mit Exit 1;
Überschreibverhalten in der README dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 00:02:50 +02:00
commit ff1a0ae5e4
3 changed files with 51 additions and 3 deletions

View file

@ -102,7 +102,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) | | `--force` | aus | Bestehendes Ausgabeverzeichnis ersetzen – siehe [Sicherheit](#sicherheit-und-fehlerbehandlung) |
| `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr | | `--verbose`, `-v` | aus | Verarbeitungsschritte und Warnungen auf stderr |
| `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) | | `--dry-run` | aus | EPUB analysieren, geplante Kapiteldateien anzeigen, **nichts** schreiben (auch kein `--report`) |
| `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer | | `--report DATEI.json` | – | Bericht zusätzlich an diesen Ort schreiben. `OUTPUT/conversion-report.json` entsteht immer. Eine bestehende Datei `DATEI.json` wird ersetzt; Namen, die epub2md selbst im Ausgabeverzeichnis erzeugt (z. B. `manifest.json`), werden abgelehnt (Exit 1). Kann der Bericht nicht geschrieben werden: Exit 1, die Ausgabe ist trotzdem vollständig |
| `--version` | – | Programmversion | | `--version` | – | Programmversion |
### Rückgabecodes ### Rückgabecodes

View file

@ -141,14 +141,32 @@ def _convert(reader: EpubReader, input_path: Path, output_dir: Path, opts: Optio
if opts.dry_run: if opts.dry_run:
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
if opts.report:
_check_report_path(Path(opts.report), output_dir, files, rendered.media, opts)
_write_output(reader, input_path, output_dir, opts, files, rendered.media) _write_output(reader, input_path, output_dir, opts, files, rendered.media)
if opts.report: if opts.report:
report_path = Path(opts.report) report_path = Path(opts.report)
try:
report_path.parent.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(writer.to_json(report), encoding="utf-8") report_path.write_text(writer.to_json(report), encoding="utf-8")
except OSError as exc:
raise InputError(f"Bericht (--report) konnte nicht geschrieben werden: {report_path} "
f"({exc.strerror or exc}); die Ausgabe in {output_dir} ist vollständig.") from exc
return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir) return Result(report=report, chapters=all_chapters, files=files, output_dir=output_dir)
def _check_report_path(report_path: Path, output_dir: Path, files: dict[str, str], media: MediaRegistry,
opts: Options) -> None:
"""--report must not replace a file that epub2md itself writes into the output directory."""
if not is_inside(report_path, output_dir):
return
rel = report_path.resolve().relative_to(output_dir.resolve()).as_posix()
generated = set(files) | ({mf.output for mf in media.files.values()} if opts.extract_media else set())
if rel in generated:
raise InputError(f"--report {report_path} würde die erzeugte Datei {rel} überschreiben; "
"bitte einen anderen Dateinamen wählen.")
@dataclass @dataclass
class _Rendered: class _Rendered:
renderer: Renderer renderer: Renderer

View file

@ -107,3 +107,33 @@ def test_multiple_blank_lines_inside_code_are_kept(convert):
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001") text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert f"```python\n{code}\n```" in text, text assert f"```python\n{code}\n```" in text, text
assert "\n\n\n" not in text.replace(code, "") # outside code blank lines are still normalized assert "\n\n\n" not in text.replace(code, "") # outside code blank lines are still normalized
def _simple() -> Book:
return Book(docs=[Doc("c.xhtml", "<h1>K</h1><p>Text.</p>")])
def test_report_must_not_overwrite_generated_output_file(convert, tmp_path):
"""QA2-07: --report OUTPUT/manifest.json silently replaced the manifest with the report."""
out = tmp_path / "ziel"
run = convert(_simple(), "--report", str(out / "manifest.json"), out=out)
assert run.rc == 1
assert "--report" in run.stderr
assert not out.exists()
def test_report_write_error_is_input_error_not_internal(convert, tmp_path):
"""QA2-07: an unwritable --report target ended with exit 4 ("Interner Fehler")."""
import os
import stat
ro = tmp_path / "ro"
ro.mkdir()
os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR)
try:
run = convert(_simple(), "--report", str(ro / "bericht.json"), out=tmp_path / "ziel")
finally:
os.chmod(ro, stat.S_IRWXU)
assert run.rc == 1, run.stderr
assert "Interner Fehler" not in run.stderr
assert "Bericht" in run.stderr