brewdoc 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brewdoc/__init__.py +16 -0
- brewdoc/cli.py +97 -0
- brewdoc/common.py +405 -0
- brewdoc/docx.py +131 -0
- brewdoc/htmltext.py +812 -0
- brewdoc/output.py +93 -0
- brewdoc/pdf.py +1185 -0
- brewdoc/pptx.py +362 -0
- brewdoc/selfcheck.py +383 -0
- brewdoc/service.py +116 -0
- brewdoc/sheets.py +341 -0
- brewdoc-0.2.0.dist-info/METADATA +232 -0
- brewdoc-0.2.0.dist-info/RECORD +16 -0
- brewdoc-0.2.0.dist-info/WHEEL +4 -0
- brewdoc-0.2.0.dist-info/entry_points.txt +2 -0
- brewdoc-0.2.0.dist-info/licenses/LICENSE +21 -0
brewdoc/output.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Output transaction: validate every target, stage all payloads, then replace or restore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from brewdoc.common import BrewdocError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _output_targets(source: Path, out,
|
|
16
|
+
pairs: tuple[tuple[str, Path, str], ...]) -> tuple[Path, ...]:
|
|
17
|
+
"""Resolve Markdown then artifact targets; refuse source aliases, collisions, bad targets."""
|
|
18
|
+
named = (((None, Path(out), str(out)),) if out is not None else ()) + pairs
|
|
19
|
+
seen = []
|
|
20
|
+
for key, path, shown in named:
|
|
21
|
+
target = path.parent.resolve() / path.name
|
|
22
|
+
if _same_file(source, target):
|
|
23
|
+
raise BrewdocError("Markdown output aliases the source: %s" % shown if key is None
|
|
24
|
+
else "artifact output aliases the source: %s=%s" % (key, shown))
|
|
25
|
+
# Casefold + NFC on every filesystem: one plan must not depend on where it runs.
|
|
26
|
+
folded = unicodedata.normalize("NFC", str(target)).casefold()
|
|
27
|
+
for earlier_key, earlier, earlier_folded in seen:
|
|
28
|
+
if earlier_folded == folded or _same_file(earlier, target):
|
|
29
|
+
raise BrewdocError("Markdown and artifact outputs collide: %s" % shown
|
|
30
|
+
if earlier_key is None else
|
|
31
|
+
"artifact outputs collide: %s and %s" % (earlier_key, key))
|
|
32
|
+
if os.path.lexists(target) and not stat.S_ISREG(target.lstat().st_mode):
|
|
33
|
+
raise BrewdocError("output target is not a regular file: %s" % path)
|
|
34
|
+
parent = next((folder for folder in target.parents if folder.exists()),
|
|
35
|
+
target.parents[-1])
|
|
36
|
+
if not parent.is_dir():
|
|
37
|
+
raise BrewdocError("output parent is not a directory: %s" % parent)
|
|
38
|
+
seen.append((key, target, folded))
|
|
39
|
+
return tuple(target for _key, target, _folded in seen)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _same_file(first: Path, second: Path) -> bool:
|
|
43
|
+
return first.exists() and second.exists() and os.path.samefile(first, second)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stage(target: Path, payload: bytes, stages: list[Path]) -> Path:
|
|
47
|
+
"""Write and fsync payload to a new stage beside target with its mode; record it in stages."""
|
|
48
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
stage = target.with_name(".brewdoc-stage-" + secrets.token_hex(8))
|
|
50
|
+
stream = open(stage, "xb")
|
|
51
|
+
stages.append(stage)
|
|
52
|
+
with stream:
|
|
53
|
+
stream.write(payload)
|
|
54
|
+
stream.flush()
|
|
55
|
+
os.fsync(stream.fileno())
|
|
56
|
+
if target.exists():
|
|
57
|
+
shutil.copymode(target, stage)
|
|
58
|
+
return stage
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _restore(targets: list[Path], originals: list[bytes | None], stages: list[Path]) -> list[str]:
|
|
62
|
+
"""Put original bytes back or delete new targets; return the ones that failed."""
|
|
63
|
+
failures = []
|
|
64
|
+
for target, original in zip(targets, originals):
|
|
65
|
+
try:
|
|
66
|
+
if original is None:
|
|
67
|
+
target.unlink()
|
|
68
|
+
else:
|
|
69
|
+
os.replace(_stage(target, original, stages), target)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
failures.append("%s (%s)" % (target, exc))
|
|
72
|
+
return failures
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _write_outputs(outputs: tuple[tuple[Path, bytes], ...]) -> None:
|
|
76
|
+
"""Stage every payload, then replace targets in order; a failed replace restores earlier ones."""
|
|
77
|
+
targets = [target for target, _payload in outputs]
|
|
78
|
+
stages: list[Path] = []
|
|
79
|
+
try:
|
|
80
|
+
originals = [target.read_bytes() if target.exists() else None for target in targets]
|
|
81
|
+
staged = [_stage(target, payload, stages) for target, payload in outputs]
|
|
82
|
+
for index, (target, stage) in enumerate(zip(targets, staged)):
|
|
83
|
+
try:
|
|
84
|
+
os.replace(stage, target)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
reason = "could not replace %s: %s" % (target, exc)
|
|
87
|
+
failures = _restore(targets[:index], originals, stages)
|
|
88
|
+
if failures:
|
|
89
|
+
reason += "; could not restore %s" % ", ".join(failures)
|
|
90
|
+
raise BrewdocError(reason) from exc
|
|
91
|
+
finally:
|
|
92
|
+
for leftover in stages:
|
|
93
|
+
leftover.unlink(missing_ok=True)
|