doc-control 0.0.1.dev0__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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""The per-document store: id, backups with retention, config and log path.
|
|
2
|
+
|
|
3
|
+
Adjacent mode (default) keeps ``.doc-control/<file name>/`` next to the
|
|
4
|
+
document; the full name (``Novela.docx``, not ``Novela``) keeps same-stem files
|
|
5
|
+
such as ``Novela.docx`` and ``Novela.dotx`` from sharing a store. With
|
|
6
|
+
``DOC_CONTROL_STORE=appdata`` the store lives under the user store
|
|
7
|
+
(``DOC_CONTROL_HOME`` or the platform default) at
|
|
8
|
+
``stores/<file name>-<hash of the resolved path>/``, so same-named documents in
|
|
9
|
+
different folders never share one, while ``x/../Novela.docx``, a symlink to it
|
|
10
|
+
and (on Windows) a different drive-letter case all map to the same store.
|
|
11
|
+
|
|
12
|
+
``config.json`` is validated on load, and a bad one is ``SCHEMA_INVALID``
|
|
13
|
+
before anything is copied or pruned. A corrupt ``id`` is an error and is never
|
|
14
|
+
overwritten, since a fresh id would orphan the document's history.
|
|
15
|
+
|
|
16
|
+
Retention prunes full backups only; ``log.jsonl`` is never touched here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import copy
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import uuid
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import UTC, datetime, timedelta
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
36
|
+
from doc_control.schema import validate
|
|
37
|
+
|
|
38
|
+
STORE_DIRNAME = ".doc-control"
|
|
39
|
+
APP_NAME = "doc-control"
|
|
40
|
+
TIMESTAMP_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
41
|
+
DEFAULT_CONFIG: dict[str, Any] = {"backups": {"keep": 20, "min_days": 30}}
|
|
42
|
+
CONFIG_SCHEMA: dict[str, Any] = {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": False,
|
|
45
|
+
"properties": {
|
|
46
|
+
"backups": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": False,
|
|
49
|
+
"properties": {
|
|
50
|
+
"keep": {"type": "integer", "minimum": 0, "maximum": 100_000},
|
|
51
|
+
"min_days": {"type": "number", "minimum": 0, "maximum": 36_500},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
# ``-<n>`` disambiguates a same-second, same-sha8 backup whose bytes differ.
|
|
57
|
+
_BACKUP_RE = re.compile(r"^(?P<ts>\d{8}T\d{6}Z)-(?P<sha>[0-9a-f]{8})(?:-(?P<n>\d+))?\.docx$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reject_constant(name: str) -> Any:
|
|
61
|
+
"""``json.loads`` hook: NaN and ±Infinity are not JSON and are never valid config."""
|
|
62
|
+
raise ValueError(f"non-finite number {name} is not allowed")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sha256_of(path: Path) -> str:
|
|
66
|
+
digest = hashlib.sha256()
|
|
67
|
+
with path.open("rb") as fh:
|
|
68
|
+
while chunk := fh.read(1 << 20):
|
|
69
|
+
digest.update(chunk)
|
|
70
|
+
return digest.hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def user_home(env: Mapping[str, str] | None = None, platform: str | None = None) -> Path:
|
|
74
|
+
"""The user store: ``DOC_CONTROL_HOME`` or the platform default."""
|
|
75
|
+
env = os.environ if env is None else env
|
|
76
|
+
platform = sys.platform if platform is None else platform
|
|
77
|
+
if env.get("DOC_CONTROL_HOME"):
|
|
78
|
+
return Path(env["DOC_CONTROL_HOME"]).expanduser()
|
|
79
|
+
if platform.startswith("win"):
|
|
80
|
+
base = env.get("APPDATA")
|
|
81
|
+
return (Path(base) if base else Path("~/AppData/Roaming").expanduser()) / APP_NAME
|
|
82
|
+
if platform == "darwin":
|
|
83
|
+
return Path("~/Library/Application Support").expanduser() / APP_NAME
|
|
84
|
+
xdg = env.get("XDG_CONFIG_HOME")
|
|
85
|
+
return (Path(xdg) if xdg else Path("~/.config").expanduser()) / APP_NAME
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def store_dir(doc_path: Path, env: Mapping[str, str] | None = None) -> Path:
|
|
89
|
+
"""Where the store for ``doc_path`` lives under the configured mode."""
|
|
90
|
+
env = os.environ if env is None else env
|
|
91
|
+
doc_path = doc_path.resolve()
|
|
92
|
+
if env.get("DOC_CONTROL_STORE", "adjacent").strip().lower() == "appdata":
|
|
93
|
+
canonical = os.path.normcase(str(doc_path))
|
|
94
|
+
key = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
|
95
|
+
return user_home(env) / "stores" / f"{doc_path.name}-{key}"
|
|
96
|
+
return doc_path.parent / STORE_DIRNAME / doc_path.name
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _merge(defaults: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
100
|
+
out = copy.deepcopy(defaults)
|
|
101
|
+
for key, value in override.items():
|
|
102
|
+
if isinstance(value, Mapping) and isinstance(out.get(key), dict):
|
|
103
|
+
out[key] = _merge(out[key], value)
|
|
104
|
+
else:
|
|
105
|
+
out[key] = value
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fsync_dir(path: Path) -> None:
|
|
110
|
+
"""Flush a directory entry where the platform allows it (not on Windows)."""
|
|
111
|
+
if os.name != "posix":
|
|
112
|
+
return
|
|
113
|
+
fd = os.open(path, os.O_RDONLY)
|
|
114
|
+
try:
|
|
115
|
+
os.fsync(fd)
|
|
116
|
+
finally:
|
|
117
|
+
os.close(fd)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_text_atomic(path: Path, text: str) -> None:
|
|
121
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
122
|
+
try:
|
|
123
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
124
|
+
fh.write(text)
|
|
125
|
+
fh.flush()
|
|
126
|
+
os.fsync(fh.fileno())
|
|
127
|
+
Path(tmp).replace(path)
|
|
128
|
+
except BaseException:
|
|
129
|
+
Path(tmp).unlink(missing_ok=True)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class DocStore:
|
|
135
|
+
root: Path
|
|
136
|
+
doc_id: str = ""
|
|
137
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def id_path(self) -> Path:
|
|
141
|
+
return self.root / "id"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def backups_dir(self) -> Path:
|
|
145
|
+
return self.root / "backups"
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def config_path(self) -> Path:
|
|
149
|
+
return self.root / "config.json"
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def log_path(self) -> Path:
|
|
153
|
+
return self.root / "log.jsonl"
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def open(cls, doc_path: Path, env: Mapping[str, str] | None = None) -> DocStore:
|
|
157
|
+
"""Open (creating if needed) the store for ``doc_path``.
|
|
158
|
+
|
|
159
|
+
Raises ``SCHEMA_INVALID`` for a bad ``config.json`` and ``INTERNAL``
|
|
160
|
+
for a corrupt ``id``, before any backup is taken.
|
|
161
|
+
"""
|
|
162
|
+
store = cls(store_dir(doc_path, env))
|
|
163
|
+
# Read and validate everything first: an error path creates nothing.
|
|
164
|
+
config = store._load_config()
|
|
165
|
+
doc_id = store._load_id()
|
|
166
|
+
try:
|
|
167
|
+
store.backups_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
except OSError as exc: # the root or ``backups`` is a regular file, or no permission
|
|
169
|
+
raise DocControlError(
|
|
170
|
+
ErrorCode.INTERNAL,
|
|
171
|
+
f"cannot create the document store at {store.backups_dir}: {exc}",
|
|
172
|
+
path=str(store.backups_dir),
|
|
173
|
+
) from exc
|
|
174
|
+
try:
|
|
175
|
+
if config is None:
|
|
176
|
+
_write_text_atomic(store.config_path, json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
|
|
177
|
+
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
178
|
+
if doc_id is None:
|
|
179
|
+
doc_id = str(uuid.uuid4())
|
|
180
|
+
_write_text_atomic(store.id_path, doc_id + "\n")
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise DocControlError(
|
|
183
|
+
ErrorCode.INTERNAL,
|
|
184
|
+
f"cannot initialise the document store at {store.root}: {exc}",
|
|
185
|
+
path=str(store.root),
|
|
186
|
+
) from exc
|
|
187
|
+
store.config = config
|
|
188
|
+
store.doc_id = doc_id
|
|
189
|
+
return store
|
|
190
|
+
|
|
191
|
+
def _load_id(self) -> str | None:
|
|
192
|
+
"""The stored id, or None when there is none yet; never rewrites it.
|
|
193
|
+
|
|
194
|
+
A dangling symlink is not "none yet": it is reported as corrupt.
|
|
195
|
+
"""
|
|
196
|
+
if not os.path.lexists(self.id_path):
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
return str(uuid.UUID(self.id_path.read_text(encoding="utf-8").strip()))
|
|
200
|
+
except (OSError, ValueError) as exc:
|
|
201
|
+
# Never mint a replacement: a new id would orphan the history
|
|
202
|
+
# logged under the old one, so a human has to look at it.
|
|
203
|
+
raise DocControlError(
|
|
204
|
+
ErrorCode.INTERNAL,
|
|
205
|
+
f"document store id is corrupt or unreadable: {self.id_path} ({exc})",
|
|
206
|
+
path=str(self.id_path),
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
def _load_config(self) -> dict[str, Any] | None:
|
|
210
|
+
"""The validated config merged over defaults, or None when the file is absent.
|
|
211
|
+
|
|
212
|
+
A dangling symlink is not absent: reading it fails as ``SCHEMA_INVALID``.
|
|
213
|
+
"""
|
|
214
|
+
if not os.path.lexists(self.config_path):
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
loaded = json.loads(
|
|
218
|
+
self.config_path.read_text(encoding="utf-8"), parse_constant=_reject_constant
|
|
219
|
+
)
|
|
220
|
+
except (OSError, ValueError) as exc: # unreadable, bad JSON, bad UTF-8, NaN/Infinity
|
|
221
|
+
raise DocControlError(
|
|
222
|
+
ErrorCode.SCHEMA_INVALID,
|
|
223
|
+
f"{self.config_path} is not readable, valid JSON: {exc}",
|
|
224
|
+
path=str(self.config_path),
|
|
225
|
+
) from exc
|
|
226
|
+
try:
|
|
227
|
+
validate(loaded, CONFIG_SCHEMA)
|
|
228
|
+
except DocControlError as exc:
|
|
229
|
+
raise DocControlError(
|
|
230
|
+
ErrorCode.SCHEMA_INVALID,
|
|
231
|
+
f"{self.config_path}: {exc.message}",
|
|
232
|
+
path=str(self.config_path),
|
|
233
|
+
field=exc.details.get("path"),
|
|
234
|
+
) from exc
|
|
235
|
+
return _merge(DEFAULT_CONFIG, loaded)
|
|
236
|
+
|
|
237
|
+
def backups(self) -> list[tuple[datetime, Path]]:
|
|
238
|
+
"""Backups in this store, oldest first; files not named like a backup are ignored."""
|
|
239
|
+
found: list[tuple[datetime, Path]] = []
|
|
240
|
+
for path in self.backups_dir.iterdir():
|
|
241
|
+
match = _BACKUP_RE.match(path.name)
|
|
242
|
+
if match and path.is_file():
|
|
243
|
+
when = datetime.strptime(match["ts"], TIMESTAMP_FORMAT).replace(tzinfo=UTC)
|
|
244
|
+
found.append((when, path))
|
|
245
|
+
found.sort()
|
|
246
|
+
return found
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _now(now: datetime | None) -> datetime:
|
|
250
|
+
if now is None:
|
|
251
|
+
return datetime.now(UTC)
|
|
252
|
+
return now if now.tzinfo else now.replace(tzinfo=UTC)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def create_backup(
|
|
256
|
+
store: DocStore,
|
|
257
|
+
doc_path: Path,
|
|
258
|
+
now: datetime | None = None,
|
|
259
|
+
expect_sha256: str | None = None,
|
|
260
|
+
) -> Path:
|
|
261
|
+
"""Copy ``doc_path`` into ``store`` as ``<UTC timestamp>-<sha8>.docx``, flushed to disk.
|
|
262
|
+
|
|
263
|
+
The bytes are hashed as they are copied, so the name always matches the
|
|
264
|
+
content. If ``expect_sha256`` is given and the copied bytes differ, the
|
|
265
|
+
partial backup is removed and ``DOC_CHANGED_ON_DISK`` is raised. The final
|
|
266
|
+
name is claimed without clobbering (hard link, then unlink the temp file),
|
|
267
|
+
so a concurrent creator's file is never overwritten. An I/O failure is
|
|
268
|
+
``INTERNAL`` and leaves no file behind.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
return _create_backup(store, doc_path, now, expect_sha256)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
raise DocControlError(
|
|
274
|
+
ErrorCode.INTERNAL,
|
|
275
|
+
f"could not back up {doc_path.name} into {store.backups_dir}: {exc}",
|
|
276
|
+
path=str(doc_path),
|
|
277
|
+
backups_dir=str(store.backups_dir),
|
|
278
|
+
) from exc
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _is_existing_backup_of(dest: Path, doc_path: Path, sha256: str) -> bool:
|
|
282
|
+
"""True only for a real, independent copy holding exactly these bytes.
|
|
283
|
+
|
|
284
|
+
A symlink or hard link (to the live document or anything else) is not a
|
|
285
|
+
backup: it would change along with its target. Unreadable means "no".
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
st = dest.lstat()
|
|
289
|
+
if dest.is_symlink() or not dest.is_file() or st.st_nlink > 1:
|
|
290
|
+
return False
|
|
291
|
+
if dest.samefile(doc_path):
|
|
292
|
+
return False
|
|
293
|
+
return _sha256_of(dest) == sha256
|
|
294
|
+
except OSError:
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _claim_name(tmp_path: Path, dest: Path) -> None:
|
|
299
|
+
"""Give ``tmp_path`` the name ``dest`` without ever replacing an existing file.
|
|
300
|
+
|
|
301
|
+
Raises ``FileExistsError`` if ``dest`` exists (or appears meanwhile).
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
os.link(tmp_path, dest)
|
|
305
|
+
except FileExistsError:
|
|
306
|
+
raise
|
|
307
|
+
except (OSError, NotImplementedError):
|
|
308
|
+
if os.name == "nt":
|
|
309
|
+
tmp_path.rename(dest) # no hard links here; Windows rename never clobbers
|
|
310
|
+
return
|
|
311
|
+
_copy_exclusive(tmp_path, dest) # e.g. FAT/exFAT or CIFS: no hard links
|
|
312
|
+
tmp_path.unlink()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _copy_exclusive(src: Path, dest: Path) -> None:
|
|
316
|
+
"""Copy ``src`` to a new file ``dest``; ``FileExistsError`` if it exists."""
|
|
317
|
+
fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
|
|
318
|
+
try:
|
|
319
|
+
with os.fdopen(fd, "wb") as out, src.open("rb") as inp:
|
|
320
|
+
while chunk := inp.read(1 << 20):
|
|
321
|
+
out.write(chunk)
|
|
322
|
+
out.flush()
|
|
323
|
+
os.fsync(out.fileno())
|
|
324
|
+
except BaseException:
|
|
325
|
+
dest.unlink(missing_ok=True)
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _create_backup(
|
|
330
|
+
store: DocStore,
|
|
331
|
+
doc_path: Path,
|
|
332
|
+
now: datetime | None,
|
|
333
|
+
expect_sha256: str | None,
|
|
334
|
+
) -> Path:
|
|
335
|
+
stamp = _now(now).astimezone(UTC).strftime(TIMESTAMP_FORMAT)
|
|
336
|
+
digest = hashlib.sha256()
|
|
337
|
+
fd, tmp = tempfile.mkstemp(dir=store.backups_dir, prefix=".backup.", suffix=".tmp")
|
|
338
|
+
tmp_path = Path(tmp)
|
|
339
|
+
try:
|
|
340
|
+
with os.fdopen(fd, "wb") as out, doc_path.open("rb") as src:
|
|
341
|
+
while chunk := src.read(1 << 20):
|
|
342
|
+
digest.update(chunk)
|
|
343
|
+
out.write(chunk)
|
|
344
|
+
out.flush()
|
|
345
|
+
os.fsync(out.fileno())
|
|
346
|
+
actual = digest.hexdigest()
|
|
347
|
+
if expect_sha256 is not None and actual != expect_sha256.strip().lower():
|
|
348
|
+
raise DocControlError(
|
|
349
|
+
ErrorCode.DOC_CHANGED_ON_DISK,
|
|
350
|
+
f"{doc_path.name} changed on disk since it was read",
|
|
351
|
+
path=str(doc_path),
|
|
352
|
+
expected_sha256=expect_sha256,
|
|
353
|
+
actual_sha256=actual,
|
|
354
|
+
)
|
|
355
|
+
base = f"{stamp}-{actual[:8]}"
|
|
356
|
+
dest = store.backups_dir / f"{base}.docx"
|
|
357
|
+
n = 0
|
|
358
|
+
while True:
|
|
359
|
+
if not os.path.lexists(dest):
|
|
360
|
+
try:
|
|
361
|
+
_claim_name(tmp_path, dest)
|
|
362
|
+
break
|
|
363
|
+
except FileExistsError:
|
|
364
|
+
continue # someone created it meanwhile: judge it below
|
|
365
|
+
if _is_existing_backup_of(dest, doc_path, actual):
|
|
366
|
+
tmp_path.unlink()
|
|
367
|
+
return dest # same content in the same second: already backed up
|
|
368
|
+
n += 1 # taken by different bytes, a link or an unreadable file
|
|
369
|
+
dest = store.backups_dir / f"{base}-{n}.docx"
|
|
370
|
+
except BaseException:
|
|
371
|
+
tmp_path.unlink(missing_ok=True)
|
|
372
|
+
raise
|
|
373
|
+
_fsync_dir(store.backups_dir)
|
|
374
|
+
return dest
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def prune_backups(
|
|
378
|
+
store: DocStore, now: datetime | None = None, protect: Path | None = None
|
|
379
|
+
) -> list[Path]:
|
|
380
|
+
"""Delete backups beyond retention; return what was removed, oldest first.
|
|
381
|
+
|
|
382
|
+
A backup is kept if it is among the newest ``backups.keep`` (at least 1) or
|
|
383
|
+
younger than ``backups.min_days``, whichever keeps more. ``protect`` (the
|
|
384
|
+
backup just taken) is never removed: timestamps are truncated to the
|
|
385
|
+
second, so it need not sort last. The log is never deleted.
|
|
386
|
+
"""
|
|
387
|
+
settings = store.config.get("backups", {})
|
|
388
|
+
keep = max(1, int(settings.get("keep", DEFAULT_CONFIG["backups"]["keep"])))
|
|
389
|
+
min_days = max(0, float(settings.get("min_days", DEFAULT_CONFIG["backups"]["min_days"])))
|
|
390
|
+
cutoff = _now(now) - timedelta(days=min_days)
|
|
391
|
+
entries = store.backups()
|
|
392
|
+
newest = {path for _, path in entries[-keep:]}
|
|
393
|
+
removed: list[Path] = []
|
|
394
|
+
for when, path in entries:
|
|
395
|
+
if path in newest or path == protect or when > cutoff:
|
|
396
|
+
continue
|
|
397
|
+
path.unlink(missing_ok=True)
|
|
398
|
+
removed.append(path)
|
|
399
|
+
return removed
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def backup(
|
|
403
|
+
doc_path: Path,
|
|
404
|
+
now: datetime | None = None,
|
|
405
|
+
env: Mapping[str, str] | None = None,
|
|
406
|
+
expect_sha256: str | None = None,
|
|
407
|
+
) -> Path:
|
|
408
|
+
"""Back up ``doc_path`` into its store, apply retention, return the backup path.
|
|
409
|
+
|
|
410
|
+
The store's config and id are validated before anything is copied or pruned.
|
|
411
|
+
"""
|
|
412
|
+
store = DocStore.open(doc_path, env)
|
|
413
|
+
path = create_backup(store, doc_path, now, expect_sha256)
|
|
414
|
+
prune_backups(store, now, protect=path)
|
|
415
|
+
return path
|