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,357 @@
1
+ """Write a document through the safety pipeline: back up, write, verify, auto-restore.
2
+
3
+ A write runs five stages, each a seam for fault injection (``backup``,
4
+ ``temp_write``, ``fsync``, ``replace``, ``verify``). The document itself is only
5
+ touched by the atomic replace, so a failure before it leaves the original
6
+ alone; a failure at or after it (including a verification that finds a change
7
+ outside the declared footprint) puts the backup back with the same atomic
8
+ replace. Either way the caller gets ``VERIFY_FAILED_RESTORED`` and the original
9
+ bytes, never a half-written file.
10
+
11
+ The restore never destroys anyone else's work. Just before the replace the
12
+ document is re-checked (lock file, sha256), and it is put back only if it is
13
+ missing or holds exactly the bytes we wrote: our own replace swaps in a fully
14
+ fsynced temp file atomically, so it can never leave a torn copy, and an empty or
15
+ partial file is another program mid-save. If anything else is on disk (Word or
16
+ a sync client saved in between), that file is left in place, a copy of it is
17
+ saved in the backups folder as
18
+ ``foreign-<sha8>-<UTC stamp>.docx`` (a name retention never prunes) and the
19
+ error is ``DOC_CHANGED_ON_DISK``. Reads of the document are retried briefly on
20
+ ``PermissionError``, as antivirus scanners on Windows hold files for a moment.
21
+
22
+ Unavoidable race: between the restore's last read of the document and its
23
+ replace another program can still save, and that save is lost; the window is
24
+ one read and one rename.
25
+
26
+ Known gap: a process killed between the replace and the verification leaves
27
+ the new file on disk with no marker saying it was never verified; the backup
28
+ taken before it is the way back.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import time
35
+ import xml.etree.ElementTree as ET
36
+ from collections.abc import Callable, Mapping
37
+ from dataclasses import dataclass
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from typing import TypeVar
41
+
42
+ from doc_control.errors import DocControlError, ErrorCode
43
+ from doc_control.ooxml.splice import scan
44
+ from doc_control.ooxml.zipio import (
45
+ Package,
46
+ build_package,
47
+ commit_bytes,
48
+ restore_file,
49
+ sha256_file,
50
+ )
51
+ from doc_control.safety import guard_write
52
+ from doc_control.safety.lock import check_unlocked
53
+ from doc_control.safety.store import store_dir
54
+
55
+ DOCUMENT_PART = "word/document.xml"
56
+ STAGES = ("backup", "temp_write", "fsync", "replace", "verify")
57
+ _READ_RETRY_DELAYS = (0.05, 0.1, 0.2)
58
+ _T = TypeVar("_T")
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Footprint:
63
+ """What a write is allowed to change.
64
+
65
+ ``replaced`` are the indices (in the original ``word/document.xml``) of
66
+ paragraphs that are rewritten or deleted, ``inserted_after`` those after
67
+ which new paragraphs land (``-1``: at the start of the body), ``inserted_before``
68
+ those before which new paragraphs land. ``parts`` are
69
+ the other zip members that may change or be created; every member not
70
+ listed is checked byte for byte, header fields included.
71
+ """
72
+
73
+ replaced: frozenset[int] = frozenset()
74
+ inserted_after: frozenset[int] = frozenset()
75
+ parts: frozenset[str] = frozenset()
76
+ inserted_before: frozenset[int] = frozenset()
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class WriteResult:
81
+ before_sha256: str
82
+ after_sha256: str
83
+ backup: Path
84
+
85
+
86
+ class _FootprintViolation(Exception):
87
+ def __init__(self, message: str, problems: list[str]) -> None:
88
+ super().__init__(message)
89
+ self.problems = problems
90
+
91
+
92
+ def verify_footprint(original: Package, result: Package, footprint: Footprint) -> list[str]:
93
+ """Problems found comparing ``result`` with ``original``; empty means all is as declared."""
94
+ problems: list[str] = []
95
+ before, after = original.names, result.names
96
+ if after[: len(before)] != before:
97
+ problems.append("zip member names or order changed")
98
+ added = [n for n in after[len(before) :] if n not in footprint.parts]
99
+ if added:
100
+ problems.append(f"members added outside the footprint: {', '.join(added)}")
101
+ if result.comment != original.comment:
102
+ problems.append("zip comment changed")
103
+ for name in before:
104
+ if name == DOCUMENT_PART or name not in result:
105
+ continue
106
+ if name not in footprint.parts and result.fingerprint(name) != original.fingerprint(name):
107
+ problems.append(f"member changed outside the footprint: {name}")
108
+ if DOCUMENT_PART in result:
109
+ problems.extend(_document_problems(original, result, footprint))
110
+ for name in after:
111
+ if (
112
+ name != DOCUMENT_PART
113
+ and name.endswith((".xml", ".rels"))
114
+ and _changed(original, result, name)
115
+ ):
116
+ try:
117
+ ET.fromstring(result.read(name))
118
+ except ET.ParseError as exc:
119
+ problems.append(f"{name} does not parse: {exc}")
120
+ return problems
121
+
122
+
123
+ def _changed(original: Package, result: Package, name: str) -> bool:
124
+ return name not in original or original.read(name) != result.read(name)
125
+
126
+
127
+ def _document_problems(original: Package, result: Package, footprint: Footprint) -> list[str]:
128
+ old, new = original.read(DOCUMENT_PART), result.read(DOCUMENT_PART)
129
+ try:
130
+ spans = scan(old)
131
+ scan(new)
132
+ ET.fromstring(new)
133
+ except (DocControlError, ET.ParseError) as exc:
134
+ return [f"{DOCUMENT_PART} does not parse: {exc}"]
135
+ cuts: list[tuple[int, int]] = []
136
+ for i in footprint.replaced:
137
+ if not 0 <= i < len(spans.paragraphs):
138
+ return [f"footprint names paragraph {i}, but the document has {len(spans.paragraphs)}"]
139
+ cuts.append((spans.paragraphs[i].start, spans.paragraphs[i].end))
140
+ for i in footprint.inserted_after:
141
+ if i == -1 and spans.body_content is not None:
142
+ cuts.append((spans.body_content, spans.body_content))
143
+ elif 0 <= i < len(spans.paragraphs):
144
+ cuts.append((spans.paragraphs[i].end, spans.paragraphs[i].end))
145
+ else:
146
+ return [f"footprint inserts after paragraph {i}, which does not exist"]
147
+ for i in footprint.inserted_before:
148
+ if not 0 <= i < len(spans.paragraphs):
149
+ return [f"footprint inserts before paragraph {i}, which does not exist"]
150
+ cuts.append((spans.paragraphs[i].start, spans.paragraphs[i].start))
151
+ # Everything between the touched spans must reappear, in order, in the result.
152
+ gaps: list[bytes] = []
153
+ pos = 0
154
+ for start, end in sorted(cuts):
155
+ if start >= pos:
156
+ gaps.append(old[pos:start])
157
+ pos = end
158
+ else:
159
+ pos = max(pos, end) # nested or overlapping spans: one touched region
160
+ gaps.append(old[pos:])
161
+ if _gaps_match(gaps, new):
162
+ return []
163
+ return [f"{DOCUMENT_PART} changed outside the declared paragraphs"]
164
+
165
+
166
+ def _gaps_match(gaps: list[bytes], new: bytes) -> bool:
167
+ if len(gaps) == 1:
168
+ return new == gaps[0]
169
+ first, last = gaps[0], gaps[-1]
170
+ if not (new.startswith(first) and new.endswith(last)):
171
+ return False
172
+ pos, end = len(first), len(new) - len(last)
173
+ if end < pos:
174
+ return False
175
+ for gap in gaps[1:-1]:
176
+ at = new.find(gap, pos, end)
177
+ if at < 0:
178
+ return False
179
+ pos = at + len(gap)
180
+ return True
181
+
182
+
183
+ def write_verified(
184
+ doc_path: Path,
185
+ replacements: Mapping[str, bytes],
186
+ footprint: Footprint,
187
+ expect_sha256: str | None = None,
188
+ *,
189
+ now: datetime | None = None,
190
+ env: Mapping[str, str] | None = None,
191
+ stage: Callable[[str], None] | None = None,
192
+ ) -> WriteResult:
193
+ """Apply ``replacements`` to ``doc_path`` and verify the result against ``footprint``.
194
+
195
+ Errors raised by the checks around the backup (``DOC_NOT_FOUND``,
196
+ ``DOC_LOCKED``, ``DOC_CHANGED_ON_DISK``, ``SCHEMA_INVALID``, ``INTERNAL``)
197
+ and by the re-check just before the replace pass through unchanged, with
198
+ the document untouched; so does ``DOC_LOCKED`` when the replace is still
199
+ denied after its retry. After that any failure, whatever the stage, puts
200
+ the original back if the document was touched (see the module docstring for
201
+ when it is not) and raises ``VERIFY_FAILED_RESTORED`` naming the stage. If
202
+ the restore cannot be confirmed byte for byte the error is ``INTERNAL`` and
203
+ names the backup; an interrupt is re-raised with that error as its cause.
204
+ ``stage``, if given, is called with each name in ``STAGES`` just before that
205
+ step runs: tests raise from it to inject a fault.
206
+ """
207
+ for name, data in replacements.items():
208
+ if not isinstance(data, bytes):
209
+ raise TypeError(f"replacement for {name} must be bytes")
210
+ current = "backup"
211
+ touched = False # the replace may have happened: the document can differ from the original
212
+ backup_path: Path | None = None
213
+ created_backup = False # this call made the file, so it may delete it
214
+ original_sha = ""
215
+ payload = b""
216
+
217
+ def mark(name: str) -> None:
218
+ nonlocal current, touched
219
+ current = name
220
+ if stage is not None:
221
+ stage(name)
222
+ if name == "replace":
223
+ check_unlocked(doc_path)
224
+ _check_unchanged(doc_path, original_sha)
225
+ touched = True
226
+
227
+ try:
228
+ mark("backup")
229
+ known = _existing_backups(doc_path, env)
230
+ backup_path = guard_write(doc_path, expect_sha256, now=now, env=env)
231
+ created_backup = backup_path not in known
232
+ pkg = Package(doc_path)
233
+ if sha256_file(backup_path) != pkg.sha256:
234
+ if created_backup:
235
+ backup_path.unlink(missing_ok=True) # a torn copy is no backup
236
+ backup_path = None
237
+ raise DocControlError(
238
+ ErrorCode.DOC_CHANGED_ON_DISK,
239
+ f"{doc_path.name} changed on disk while it was backed up",
240
+ path=str(doc_path),
241
+ )
242
+ original_sha = pkg.sha256
243
+ payload = build_package(pkg, replacements)
244
+ after_sha = hashlib.sha256(payload).hexdigest()
245
+ commit_bytes(payload, doc_path, mark)
246
+ mark("verify")
247
+ if _retry(sha256_file, doc_path) != after_sha:
248
+ raise OSError(f"{doc_path.name} does not hold the bytes that were written")
249
+ problems = verify_footprint(pkg, _retry(Package, doc_path), footprint)
250
+ if problems:
251
+ raise _FootprintViolation("; ".join(problems), problems)
252
+ except BaseException as exc:
253
+ if touched and backup_path is not None:
254
+ try:
255
+ _restore(doc_path, backup_path, original_sha, payload)
256
+ except DocControlError as restore_error:
257
+ if isinstance(exc, Exception):
258
+ raise restore_error from exc
259
+ raise exc from restore_error
260
+ if not isinstance(exc, Exception):
261
+ raise
262
+ if isinstance(exc, DocControlError) and current in ("backup", "replace"):
263
+ raise # a refusal before anything was written: nothing to restore, nothing to wrap
264
+ if current == "replace" and isinstance(exc, PermissionError):
265
+ raise DocControlError(
266
+ ErrorCode.DOC_LOCKED,
267
+ f"could not replace {doc_path.name}, it is held open: {exc}",
268
+ path=str(doc_path),
269
+ ) from exc
270
+ raise DocControlError(
271
+ ErrorCode.VERIFY_FAILED_RESTORED,
272
+ f"write failed at stage {current}, original kept: {exc}",
273
+ stage=current,
274
+ path=str(doc_path),
275
+ cause=f"{type(exc).__name__}: {exc}",
276
+ **({"problems": exc.problems} if isinstance(exc, _FootprintViolation) else {}),
277
+ ) from exc
278
+ return WriteResult(original_sha, after_sha, backup_path)
279
+
280
+
281
+ def _existing_backups(doc_path: Path, env: Mapping[str, str] | None) -> set[Path]:
282
+ try:
283
+ return set((store_dir(doc_path, env) / "backups").iterdir())
284
+ except OSError:
285
+ return set()
286
+
287
+
288
+ def _retry(read: Callable[[Path], _T], path: Path) -> _T:
289
+ """``read(path)``, retried a few times on ``PermissionError`` (a scanner holding the file)."""
290
+ for delay in _READ_RETRY_DELAYS:
291
+ try:
292
+ return read(path)
293
+ except PermissionError:
294
+ time.sleep(delay)
295
+ return read(path)
296
+
297
+
298
+ def _check_unchanged(doc_path: Path, original_sha: str) -> None:
299
+ actual = _retry(sha256_file, doc_path)
300
+ if actual != original_sha:
301
+ raise DocControlError(
302
+ ErrorCode.DOC_CHANGED_ON_DISK,
303
+ f"{doc_path.name} changed on disk while the write was prepared",
304
+ path=str(doc_path),
305
+ expected_sha256=original_sha,
306
+ actual_sha256=actual,
307
+ )
308
+
309
+
310
+ def _restore(doc_path: Path, backup_path: Path, original_sha: str, written: bytes) -> None:
311
+ """Put the backup back if the document is missing or exactly what we wrote.
312
+
313
+ Anything else (an empty or partial file included) was saved by someone else
314
+ since our replace: leave it, keep a copy of it in the backups folder and raise
315
+ ``DOC_CHANGED_ON_DISK``.
316
+ """
317
+
318
+ def internal(why: str) -> DocControlError:
319
+ return DocControlError(
320
+ ErrorCode.INTERNAL,
321
+ f"{why} {doc_path.name}; the original is at {backup_path}",
322
+ path=str(doc_path),
323
+ backup=str(backup_path),
324
+ )
325
+
326
+ try:
327
+ current: bytes | None = _retry(Path.read_bytes, doc_path)
328
+ except FileNotFoundError:
329
+ current = None
330
+ except OSError as exc:
331
+ raise internal("could not inspect") from exc
332
+ if current is not None:
333
+ digest = hashlib.sha256(current).hexdigest()
334
+ if digest == original_sha:
335
+ return
336
+ if digest != hashlib.sha256(written).hexdigest():
337
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
338
+ copy = backup_path.parent / f"foreign-{digest[:8]}-{stamp}.docx"
339
+ try:
340
+ commit_bytes(current, copy, None)
341
+ except OSError as exc:
342
+ raise internal("could not save a copy of the newer") from exc
343
+ raise DocControlError(
344
+ ErrorCode.DOC_CHANGED_ON_DISK,
345
+ f"{doc_path.name} was saved by another program during the write; "
346
+ "it was left as it is and a copy was kept",
347
+ path=str(doc_path),
348
+ saved_copy=str(copy),
349
+ actual_sha256=digest,
350
+ )
351
+ try:
352
+ restore_file(backup_path, doc_path)
353
+ restored = _retry(sha256_file, doc_path) == original_sha
354
+ except OSError as exc:
355
+ raise internal("could not restore") from exc
356
+ if not restored:
357
+ raise internal("could not restore")
doc_control/schema.py ADDED
@@ -0,0 +1,143 @@
1
+ """A small JSON Schema subset validator (stdlib only).
2
+
3
+ Supported keywords: type, properties, required, additionalProperties (bool),
4
+ enum, items, minimum, maximum, minLength, description, default.
5
+ `check_schema` verifies a schema uses only that subset, correctly; `validate`
6
+ checks a value against a schema that passed it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ from doc_control.errors import DocControlError, ErrorCode
15
+
16
+ _TYPES: dict[str, tuple[type, ...]] = {
17
+ "object": (dict,),
18
+ "array": (list,),
19
+ "string": (str,),
20
+ "integer": (int,),
21
+ "number": (int, float),
22
+ "boolean": (bool,),
23
+ "null": (type(None),),
24
+ }
25
+
26
+ _KEYWORDS = frozenset(
27
+ {
28
+ "type",
29
+ "properties",
30
+ "required",
31
+ "additionalProperties",
32
+ "enum",
33
+ "items",
34
+ "minimum",
35
+ "maximum",
36
+ "minLength",
37
+ "description",
38
+ "default",
39
+ }
40
+ )
41
+
42
+
43
+ def check_schema(schema: Any, path: str = "$") -> list[str]:
44
+ """Return every way `schema` strays from the supported subset; [] means well formed."""
45
+ if not isinstance(schema, dict):
46
+ return [f"{path} must be a schema object"]
47
+ problems = [f"{path}.{k} is not a supported keyword" for k in schema if k not in _KEYWORDS]
48
+ if "type" in schema:
49
+ t = schema["type"]
50
+ names = t if isinstance(t, list) else [t]
51
+ if not names or not all(isinstance(n, str) and n in _TYPES for n in names):
52
+ problems.append(f"{path}.type must name known types: {sorted(_TYPES)}")
53
+ props = schema.get("properties", {})
54
+ if not isinstance(props, dict):
55
+ problems.append(f"{path}.properties must be an object")
56
+ props = {}
57
+ for key, sub in props.items():
58
+ problems += check_schema(sub, f"{path}.properties.{key}")
59
+ if "required" in schema:
60
+ req = schema["required"]
61
+ if not isinstance(req, list) or not all(isinstance(r, str) for r in req):
62
+ problems.append(f"{path}.required must be a list of property names")
63
+ else:
64
+ problems += [f"{path}.required names undeclared {r!r}" for r in req if r not in props]
65
+ if "additionalProperties" in schema and not isinstance(schema["additionalProperties"], bool):
66
+ problems.append(f"{path}.additionalProperties must be true or false")
67
+ if "enum" in schema and not (isinstance(schema["enum"], list) and schema["enum"]):
68
+ problems.append(f"{path}.enum must be a non-empty list")
69
+ if "items" in schema:
70
+ problems += check_schema(schema["items"], f"{path}.items")
71
+ for bound in ("minimum", "maximum"):
72
+ if bound in schema and not _is_finite_number(schema[bound]):
73
+ problems.append(f"{path}.{bound} must be a finite number")
74
+ if "minLength" in schema:
75
+ n = schema["minLength"]
76
+ if not isinstance(n, int) or isinstance(n, bool) or n < 0:
77
+ problems.append(f"{path}.minLength must be a non-negative integer")
78
+ if "description" in schema and not isinstance(schema["description"], str):
79
+ problems.append(f"{path}.description must be a string")
80
+ return problems
81
+
82
+
83
+ def validate(value: Any, schema: dict[str, Any], path: str = "$") -> None:
84
+ """Raise SCHEMA_INVALID with the JSON path of the first violation."""
85
+ expected = schema.get("type")
86
+ names: list[str] = []
87
+ if expected is not None:
88
+ names = expected if isinstance(expected, list) else [expected]
89
+ ok = any(
90
+ isinstance(value, _TYPES[n])
91
+ and not (n in ("integer", "number") and isinstance(value, bool))
92
+ for n in names
93
+ )
94
+ if not ok:
95
+ _fail(path, f"expected {'/'.join(names)}")
96
+ numeric = bool({"integer", "number"} & set(names)) or "minimum" in schema or "maximum" in schema
97
+ if numeric and isinstance(value, float) and not math.isfinite(value):
98
+ _fail(path, "must be a finite number")
99
+ if "enum" in schema and not any(_json_equal(value, v) for v in schema["enum"]):
100
+ _fail(path, f"must be one of {schema['enum']}")
101
+ if isinstance(value, str) and len(value) < schema.get("minLength", 0):
102
+ _fail(path, f"shorter than {schema['minLength']}")
103
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
104
+ if "minimum" in schema and value < schema["minimum"]:
105
+ _fail(path, f"below minimum {schema['minimum']}")
106
+ if "maximum" in schema and value > schema["maximum"]:
107
+ _fail(path, f"above maximum {schema['maximum']}")
108
+ if isinstance(value, dict):
109
+ props: dict[str, Any] = schema.get("properties", {})
110
+ for key in schema.get("required", []):
111
+ if key not in value:
112
+ _fail(f"{path}.{key}", "is required")
113
+ if schema.get("additionalProperties", True) is False:
114
+ for key in value:
115
+ if key not in props:
116
+ _fail(f"{path}.{key}", "is not allowed")
117
+ for key, sub in props.items():
118
+ if key in value:
119
+ validate(value[key], sub, f"{path}.{key}")
120
+ if isinstance(value, list) and "items" in schema:
121
+ for i, item in enumerate(value):
122
+ validate(item, schema["items"], f"{path}[{i}]")
123
+
124
+
125
+ def _json_equal(a: Any, b: Any) -> bool:
126
+ """JSON equality: booleans never equal numbers (Python's True == 1 does not apply)."""
127
+ if isinstance(a, bool) or isinstance(b, bool):
128
+ return isinstance(a, bool) and isinstance(b, bool) and a is b
129
+ if isinstance(a, list) and isinstance(b, list):
130
+ return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b, strict=True))
131
+ if isinstance(a, dict) and isinstance(b, dict):
132
+ return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a)
133
+ if isinstance(a, (list, dict)) or isinstance(b, (list, dict)):
134
+ return False
135
+ return bool(a == b)
136
+
137
+
138
+ def _is_finite_number(x: Any) -> bool:
139
+ return isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x)
140
+
141
+
142
+ def _fail(path: str, why: str) -> None:
143
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, f"{path} {why}", path=path)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: doc-control
3
+ Version: 0.0.1.dev0
4
+ Summary: Deterministic, LLM-first control of Word documents: addresses, anchors, tracked-change batches and a safety layer.
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # doc-control
11
+
12
+ <p align="center">
13
+ <img src="docs/social-preview.png" width="100%" alt="doc-control: let AI agents edit Word documents safely. Deterministic .docx edits, every change a tracked change the writer can reject.">
14
+ </p>
15
+
16
+ > Status: **spec and plan approved** — implementation starting. See `status/specification.md` and `status/plan.md`.
17
+
18
+ A deterministic tool that lets AI agents work on Word documents quickly,
19
+ precisely and safely, ideally live in the Word window the writer has open.
20
+ Every content change is a tracked change the writer can accept or reject.
21
+
22
+ For anyone — writers and editors first — who wants an AI agent's help inside a
23
+ Word document without a single silent change. Nothing to install yet; watch the
24
+ repo for the first release.
25
+
26
+ ## Planned shape
27
+
28
+ - **One core, two engines, one API**
29
+ - *File engine*: edits a closed `.docx` with the Python standard library only,
30
+ rewriting only the parts it touches.
31
+ - *Live engine*: controls the open Word (Windows/COM first, Mac later).
32
+ - **Two front doors**: a CLI with JSON in/out, and an MCP server for Claude
33
+ Desktop / Cowork.
34
+ - **Safety first**: lock-file and on-disk change detection, backup before every
35
+ write, verification, automatic restore, operation log with undo, and
36
+ `unintended_changes == 0` as a gate.
37
+ - **No AI inside.** Judgment belongs to the agent; approval belongs to the writer.
38
+
39
+ It will replace `docx-fixer`, whose rules become packs built on this tool, and
40
+ serve `literary-critic` as its first downstream consumer.
41
+
42
+ ## Documents
43
+
44
+ - [Initial research: tooling and risks](docs/research/01-tooling-and-risks.md)
45
+
46
+ ## Development
47
+
48
+ This repo uses [claude-arsenal](https://github.com/nuncaeslupus/claude-arsenal)
49
+ (spec-driven workflow, gated tasks in `arsenal/tasks/`). See `CLAUDE.md`.
50
+
51
+ ## Related projects
52
+
53
+ Same author:
54
+
55
+ - [claude-arsenal](https://github.com/nuncaeslupus/claude-arsenal) — a Claude Code skill and plugin marketplace: specs before code, failing tests first, gates with numbers.
56
+ - [literary-critic](https://github.com/nuncaeslupus/literary-critic) — lets an LLM index, analyse and advise on a full novel manuscript the way a professional editor would; doc-control's first planned consumer.
57
+ - [drawspec](https://github.com/nuncaeslupus/drawspec) — declarative diagram spec → clean, themeable SVG; JSON with no coordinates, so an LLM can write it.
58
+ - [integral-job-search](https://github.com/nuncaeslupus/integral-job-search) — a candidate-centred job search run as a conversation in Claude Code.
59
+
60
+ ## License
61
+
62
+ [Apache-2.0](LICENSE)
@@ -0,0 +1,68 @@
1
+ doc_control/__init__.py,sha256=qeouMpK38JthTURrrfta8tXSsJ9yqSSCE-SMaiZ_EG4,114
2
+ doc_control/__main__.py,sha256=gUqz6_59npJrYQyKH7Mu4RFb3vatmSZcTeYKmXOaLBg,59
3
+ doc_control/cli.py,sha256=y_u3sviedqmfMU3KFLGbEE6HgaQaXMqYV58CbnAHtsg,3027
4
+ doc_control/envelope.py,sha256=Ycjy7a77cvR6e3tnUmzAYNjrTEwQFDurmyagAkC3roU,1089
5
+ doc_control/errors.py,sha256=YTcvZknc03_7-NBbNK_1PZN586Ny0nSjBG2jLBoQ5To,3269
6
+ doc_control/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ doc_control/registry.py,sha256=58p3zx7gk8OsjVst7pIFFdCq4FK9UdwE_IrQCBOwprQ,2051
8
+ doc_control/schema.py,sha256=RGEx1evr-gtqCH44eqHMunJt7ewdmA4o4ZZJBc_Eebc,6083
9
+ doc_control/model/__init__.py,sha256=g7XQUZZrGbpQAC_ZhtrljkAWqKnS-5Qz77CzEvTfhpY,65
10
+ doc_control/model/address.py,sha256=nxB3RyWTShhUM7SSRHH_zBgsG0eQNE-1jTVSSNqscdQ,4129
11
+ doc_control/model/align.py,sha256=Tt4BUhm0aOai3a2yTl2Pz0e2iwlsZ7tObSWrIhPS6Ug,23188
12
+ doc_control/model/anchor.py,sha256=cDn3PugCyvga7Gc7y2G6J2sxQf_9bqRGISZOlDHR3_k,23691
13
+ doc_control/model/annotations.py,sha256=0moNYUgn2-p_AgXotaH3Z-OLXSYMYxQm1o4SjQ2Dzfo,10315
14
+ doc_control/model/document.py,sha256=i_9qCxylVjzCTGlUsLEvBjYo2MyAgkxfjPjpqeAZ490,4250
15
+ doc_control/model/format.py,sha256=A-0KAHe7cm-ME8UIEN4GoS2GOlL0QY9JnqEvrm4-rh4,3632
16
+ doc_control/model/index.py,sha256=g7vd47OMToy_Td0GEOON1F4BjY5W6zqa1be7mBn3e4o,14036
17
+ doc_control/model/layout.py,sha256=E8X4OytzVfUXY-dPGpzZQuyoU_2CIWrvcijN1E_ptcQ,5623
18
+ doc_control/model/normalized.py,sha256=1-19iKRFTPlvMECt-_XQNWJPHx1JzmQO6ZkxrO0Z8gE,6860
19
+ doc_control/model/pages.py,sha256=MEUJU_uNve8XGj5MPR9hncjCj6zZMtxl-SxJe9x4V88,17225
20
+ doc_control/model/parts.py,sha256=FXD9QJ3hijR7qcUSHaD2bdPm1ctsbl85vIQuRogcSW4,1843
21
+ doc_control/model/search.py,sha256=m423tzX_N68clnQY23Xa6nAasIu4o1ayYw0sekvY7mI,16038
22
+ doc_control/model/segment.py,sha256=kFFexKltyU5Qwy79HmTvNJEdYWji87Xr6Y_Ewx9pRIU,16601
23
+ doc_control/model/structure.py,sha256=5HRtcFe_fXdkhXKUD0HGSfhyPybNn1a619GNHtgu50I,16065
24
+ doc_control/model/styles.py,sha256=0fFi5QM30mw3Vfhxz7U_NzyPyH30AwAv_JL-jgitQAo,4934
25
+ doc_control/ooxml/__init__.py,sha256=KsdBbl4PsKpGZrCErVJtvped_m9nFFJzD2OumuTnwrI,54
26
+ doc_control/ooxml/commentparts.py,sha256=KoMcUrIUtjX-5WMmg-CTA4wk3ORe9drR0Hy5rPAx3Wc,27982
27
+ doc_control/ooxml/formatting.py,sha256=TIl_Q7CUhUcjRQUylDcAsC7QN1-O5ylN6Tp9-YAO-48,6894
28
+ doc_control/ooxml/resolve.py,sha256=Q2Tq5be6gd6uRbWiWeEkvspoA7J_RqVvqx2QFAgcFdU,9304
29
+ doc_control/ooxml/revisions.py,sha256=eubBESd8fbqDzwFnPEjR2EcTR_DnnpFtu1xFQAzXTZA,41537
30
+ doc_control/ooxml/splice.py,sha256=dtr24rIFgPROeLGCSMtBGVkIrmYCF2p46UNmKV0wFMk,38371
31
+ doc_control/ooxml/styledef.py,sha256=k616LAfWS-zYkuQ8iBjMJ2KjS3kMqvLWlRsBnd1u78s,16334
32
+ doc_control/ooxml/zipio.py,sha256=n_gYTPfnQeNZIyeO0rUiRetLIYzwi2xFd5Big_FaAho,15468
33
+ doc_control/ops/__init__.py,sha256=NpPTBYBKUlCswx_bL0HOc2iSXMjX3jDWkUmoT2K2egI,66
34
+ doc_control/ops/anchor.py,sha256=KFORs-Q9JE3avevinSQEMUrP2P6UL-Fcn9A-4mLqAZw,5087
35
+ doc_control/ops/batch.py,sha256=vqzJdQIVOI_ETpL2C3Kx3RmEnZJjEoHSzlMEVlIm-pc,7607
36
+ doc_control/ops/comments.py,sha256=RHaoLhbOrJ0WFx6neuFPyMxuIVRZmWVKjFLDjg4mS98,16197
37
+ doc_control/ops/compare.py,sha256=r_Ef8VVgaDHprvlpRXnsGLFxbPxJDI2LUJ51nuOAxJc,23975
38
+ doc_control/ops/diff.py,sha256=b74kw1nq7PEVRzZop8tQ9cQCU6C--hyRvQ9s5NGKDX8,7003
39
+ doc_control/ops/dotx.py,sha256=izZ_je3rBu7lmYsEAEfqDIMLXXdVXeinBZqzlgzABMw,8518
40
+ doc_control/ops/find.py,sha256=7KJ973Ib6DJZprlLJXyuQ4N-kFg0SKoTta36_jg9H6Q,7369
41
+ doc_control/ops/history.py,sha256=6RkobVru-EiPtXLppeIxMFHhZcvNaG4RizT2sL1W3v4,18039
42
+ doc_control/ops/layout.py,sha256=uQo0pXtBj6IpTA_n4ioiMUUbH5JZxnbQI3sS8TTqivo,1397
43
+ doc_control/ops/locate.py,sha256=2nojJ6exFby6ibambgNtfuHhpHff_Jn1hWAYVjASk34,16172
44
+ doc_control/ops/meta.py,sha256=VneIJjPh53Y_9ar1c65cKFBFamUGcuQXhf3aChwleo8,1618
45
+ doc_control/ops/page.py,sha256=TTaN2gEh22aCk2o-MX9LZOWFn5Waq6v2g0WE4ZfIpQI,3613
46
+ doc_control/ops/paging.py,sha256=qVwAG7kF0wNpJWjojzBiXqtCSkGspAXrzodxbQcp2_g,4364
47
+ doc_control/ops/read.py,sha256=0So4DlIO1uMNWT_4IjPRPEz3CG4gaL9G0VS_qB1UeS0,18359
48
+ doc_control/ops/recipe.py,sha256=CdcpDwepD7tgT9WirJL9vYfrAR_hK0DXOsowB9EqbRw,3836
49
+ doc_control/ops/rename.py,sha256=IQoRrO4GC-_zVJybbhMd4ZSna42x10-vA5oIXle3a2I,14275
50
+ doc_control/ops/revision.py,sha256=fsw2gTuJaRGbk8betOqaOhyzc75UkVWG3EWZD6rkjxY,23265
51
+ doc_control/ops/segment.py,sha256=AAQ1Nxo2R3Wm84NLrgv4h9beZfcNC4R07YY1Qk5wegM,10106
52
+ doc_control/ops/styleset.py,sha256=hXOOgCtw8WYkfj8nlUSzGPuuoIhIoB1wC2q2kLf72Q4,10272
53
+ doc_control/ops/stylewrite.py,sha256=yfFWu1R0N4i0MudBNFwQ7sXllEK8IEmWdq20Yk2umpQ,21996
54
+ doc_control/ops/text.py,sha256=YUGcIzWj5sqnCiJ5oTv9CKmmT2xcQSqbOIm8V0853mA,8380
55
+ doc_control/ops/tracked.py,sha256=H95fW1PfEF7xPHMHydws5_mAGJ1BYPX1dFwhxBHxu7o,57032
56
+ doc_control/recipes/engine.py,sha256=2dmAeipkBsvEXEk2SMb5H7yYRmf48n35-_rnviJJyq4,8100
57
+ doc_control/recipes/schema.py,sha256=unwhGaTS3W2VBbfiadj-mnuTtvbzQybRJVh147yuG-4,5715
58
+ doc_control/recipes/store.py,sha256=YmBGkCDRoGEa87H-bfeWuaTT5C1VZ3CqIRhOVELX6Ls,3492
59
+ doc_control/safety/__init__.py,sha256=uAUFcCESkj6jYhkbEl4WLkTUgbBrJAkniSEQUpGCf_8,1415
60
+ doc_control/safety/history.py,sha256=ZST9xDoWCla2cZIG3hj1Se2JDmQqxAHppgqj7JtmTF4,17793
61
+ doc_control/safety/lock.py,sha256=cg-qXq-LNZkvz68rp5S24AtsQpM_f4zI6iad17v4tKY,2556
62
+ doc_control/safety/store.py,sha256=9Cmf8Hi7Ftqo4GzMyIA-8v3BjD_xPnylSglHu9glrsc,15591
63
+ doc_control/safety/verify.py,sha256=9Z_YGh6tzcHsXRjN8tCdmCu4qkdL7HEKQJMs33BsDC4,14553
64
+ doc_control-0.0.1.dev0.dist-info/METADATA,sha256=6fwf6VQk5MoGgM4A5AvbtTTPox3sR4W0uUgAP1d2iDM,2860
65
+ doc_control-0.0.1.dev0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
66
+ doc_control-0.0.1.dev0.dist-info/entry_points.txt,sha256=2nrW4FYVZaIl1WoBhFUDY1me8YE-XKF1yZamI0jQtgA,53
67
+ doc_control-0.0.1.dev0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
68
+ doc_control-0.0.1.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ doc-control = doc_control.cli:main