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,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,,
|