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.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. 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