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,403 @@
1
+ """Text search over a paragraph's visible text, with offsets in the original text.
2
+
3
+ A :class:`Matcher` finds non-empty matches of a literal or a regular expression and
4
+ reports them as ``(start, end)`` code-point offsets into the *original* visible text,
5
+ end exclusive, whatever folding was used to find them.
6
+
7
+ Units and folding
8
+ The text is cut into *units*: a base character plus the combining marks that follow
9
+ it (a lone run of marks at the very start is a unit of its own). Each unit is folded
10
+ on its own and the folded pieces are concatenated; the search runs on that folded
11
+ text. Folding is always canonical composition (NFC), so ``é`` and ``e`` + U+0301
12
+ are the same thing to a search. ``ignore_case`` also case-folds (``ß`` becomes
13
+ ``ss``); ``ignore_accents`` also drops the combining marks left after decomposition
14
+ (``á`` becomes ``a``, ``ñ`` becomes ``n``).
15
+
16
+ A match is kept only if it starts and ends on a unit boundary of the folded text:
17
+ it never begins on a mark, never ends between a letter and its marks (so ``e`` does
18
+ not match inside ``é`` unless accents are ignored), and never covers only part of
19
+ what one character folds to (``s`` does not match ``ß``; ``ss`` does). Kept matches
20
+ are mapped back to the whole original units they cover.
21
+
22
+ Marks are every character of Unicode category M (Latin and Greek accents, Hebrew
23
+ points, Arabic harakat, Indic vowel signs). Units are not full graphemes: ZWJ
24
+ sequences and flag pairs may be split, and Hangul conjoining jamo are separate units, so
25
+ they are not unified with precomposed syllables.
26
+
27
+ Regular expressions
28
+ Python ``re`` syntax, run per paragraph (a match never crosses a paragraph). With
29
+ ``ignore_accents`` the pattern is matched against the text with accents removed, so
30
+ write it without accents. ``ignore_case`` uses ``re.IGNORECASE``. ``whole_word``
31
+ requires a non-word character (combining marks count as word characters) on both
32
+ sides, and wraps the pattern in a group, so inline flags such as ``(?i)`` are not
33
+ allowed with it. Python's engine cannot be interrupted, so :class:`IsolatedScan`
34
+ runs it in a child process that is killed at a hard deadline; patterns over 1000
35
+ characters and the textbook ``(a+)+`` shape are still refused up front, as a cheap
36
+ first line of defence.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import bisect
42
+ import importlib
43
+ import itertools
44
+ import multiprocessing
45
+ import re
46
+ import time
47
+ import unicodedata
48
+ from collections.abc import Generator, Iterator, Sequence
49
+ from dataclasses import dataclass
50
+ from typing import Any
51
+
52
+ from doc_control.errors import DocControlError, ErrorCode
53
+
54
+ MAX_QUERY_LENGTH = 1000
55
+ Span = tuple[int, int]
56
+
57
+
58
+ def _mark_class() -> str:
59
+ """Regex class body of every mark (general category M*) in the planes that have any."""
60
+ out: list[str] = []
61
+ start = prev = -2
62
+ for cp in (*range(0x30000), *range(0xE0000, 0xE0200)):
63
+ if unicodedata.category(chr(cp)).startswith("M"):
64
+ if cp != prev + 1:
65
+ if start >= 0:
66
+ out.append(f"{chr(start)}-{chr(prev)}")
67
+ start = cp
68
+ prev = cp
69
+ out.append(f"{chr(start)}-{chr(prev)}")
70
+ return "".join(out)
71
+
72
+
73
+ # Marks (Latin and Greek accents, Hebrew points, Arabic harakat, Indic vowel signs, ...) for
74
+ # units and word boundaries: a letter and its marks go together.
75
+ _MARKS = _mark_class()
76
+ _WORD = rf"[\w{_MARKS}]"
77
+ _HAS_MARK = re.compile(f"[{_MARKS}]")
78
+ _UNIT = re.compile(rf"(?s:[^{_MARKS}][{_MARKS}]*|[{_MARKS}]+)")
79
+ _UNBOUNDED = 65535 # re's MAXREPEAT is far above this; bounded repeats stay below
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SearchOptions:
84
+ query: str
85
+ regex: bool = False
86
+ ignore_case: bool = False
87
+ ignore_accents: bool = False
88
+ whole_word: bool = False
89
+
90
+
91
+ def _invalid(message: str) -> DocControlError:
92
+ return DocControlError(ErrorCode.SCHEMA_INVALID, message, path="$.query")
93
+
94
+
95
+ class _Folder:
96
+ """Folds text unit by unit, remembering where each unit ends."""
97
+
98
+ def __init__(self, accents: bool, case: bool) -> None:
99
+ self.accents = accents
100
+ self.case = case
101
+ self._cache: dict[str, str] = {}
102
+
103
+ def unit(self, unit: str) -> str:
104
+ out = self._cache.get(unit)
105
+ if out is None:
106
+ out = unit
107
+ if self.case:
108
+ out = out.casefold()
109
+ if self.accents:
110
+ decomposed = unicodedata.normalize("NFD", out)
111
+ out = "".join(c for c in decomposed if not unicodedata.combining(c))
112
+ out = unicodedata.normalize("NFC", out)
113
+ self._cache[unit] = out
114
+ return out
115
+
116
+ def fold(self, text: str) -> tuple[str, list[str] | None]:
117
+ """``(folded, units)``; ``units`` is ``None`` when each character is its own unit."""
118
+ if text.isascii():
119
+ return (text.casefold() if self.case else text), None
120
+ if _HAS_MARK.search(text):
121
+ units = _UNIT.findall(text)
122
+ return "".join(map(self.unit, units)), units
123
+ return "".join(map(self.unit, text)), None
124
+
125
+
126
+ _FOLDERS: dict[tuple[bool, bool], _Folder] = {}
127
+
128
+
129
+ def _folder(accents: bool, case: bool) -> _Folder:
130
+ key = (accents, case)
131
+ if key not in _FOLDERS:
132
+ _FOLDERS[key] = _Folder(accents, case)
133
+ return _FOLDERS[key]
134
+
135
+
136
+ class Matcher:
137
+ """A compiled search; reusable across paragraphs."""
138
+
139
+ def __init__(self, options: SearchOptions) -> None:
140
+ query = options.query
141
+ if not query:
142
+ raise _invalid("query must not be empty")
143
+ if len(query) > MAX_QUERY_LENGTH:
144
+ raise _invalid(f"query is longer than {MAX_QUERY_LENGTH} characters")
145
+ flags = 0
146
+ fold_case = False
147
+ if options.regex:
148
+ if options.ignore_case:
149
+ flags = re.IGNORECASE
150
+ _check_regex(query)
151
+ pattern = query
152
+ else:
153
+ # Literals are compared on folded text, so ß matches ss and İ matches i.
154
+ fold_case = options.ignore_case
155
+ folded = _folder(options.ignore_accents, fold_case).fold(query)[0]
156
+ if not folded:
157
+ raise _invalid("query has no characters left after folding")
158
+ pattern = re.escape(folded)
159
+ if options.whole_word:
160
+ pattern = f"(?<!{_WORD})(?:{pattern})(?!{_WORD})"
161
+ try:
162
+ self._pattern = re.compile(pattern, flags)
163
+ except (re.error, RecursionError, OverflowError) as exc:
164
+ raise _invalid(f"invalid regular expression: {exc}") from exc
165
+ self._folder = _folder(options.ignore_accents, fold_case)
166
+
167
+ @property
168
+ def pattern(self) -> re.Pattern[str]:
169
+ """The compiled pattern (a regex's groups are read from it)."""
170
+ return self._pattern
171
+
172
+ def spans(self, text: str) -> Iterator[Span]:
173
+ """Non-empty matches of ``text`` as ``(start, end)`` offsets into ``text``."""
174
+ for start, end, _ in self.matches(text):
175
+ yield start, end
176
+
177
+ def matches(self, text: str) -> Iterator[Match]:
178
+ """Non-empty matches as ``(start, end, groups)``, offsets into ``text``.
179
+
180
+ Groups are the capture groups' text ("" for one that did not take part). A group
181
+ is read from ``text`` itself when its edges map back to it; when an edge falls
182
+ inside what one character folds to (``ß`` is ``ss``) the folded text is used,
183
+ so a match is never dropped for its groups.
184
+ """
185
+ folded, units = self._folder.fold(text)
186
+ found = [m for m in self._pattern.finditer(folded) if m.end() > m.start()]
187
+ if not found:
188
+ return
189
+ if text.isascii():
190
+ for m in found:
191
+ yield m.start(), m.end(), tuple(g or "" for g in m.groups())
192
+ return
193
+ parts = units if units is not None else text
194
+ pieces = [self._folder.unit(u) for u in parts]
195
+ bounds = list(itertools.accumulate(map(len, units), initial=0)) if units else None
196
+ starts = list(itertools.accumulate(map(len, pieces), initial=0))
197
+ boundaries = set(starts)
198
+
199
+ def origin(pos: int, end: bool) -> int:
200
+ i = bisect.bisect_left(starts, pos) if end else bisect.bisect_right(starts, pos) - 1
201
+ return i if bounds is None else bounds[i]
202
+
203
+ for m in found:
204
+ fs, fe = m.span()
205
+ if fs not in boundaries or fe not in boundaries:
206
+ continue # inside a unit, or inside what one character folds to
207
+ groups: list[str] = []
208
+ for n in range(1, (self._pattern.groups or 0) + 1):
209
+ gs, ge = m.span(n)
210
+ if gs < 0:
211
+ groups.append("")
212
+ elif gs in boundaries and ge in boundaries:
213
+ groups.append(text[origin(gs, False) : origin(ge, True)])
214
+ else:
215
+ groups.append(folded[gs:ge])
216
+ yield origin(fs, False), origin(fe, True), tuple(groups)
217
+
218
+
219
+ def _check_regex(pattern: str) -> None:
220
+ try:
221
+ parsed = importlib.import_module("re._parser").parse(pattern)
222
+ except (re.error, RecursionError, OverflowError) as exc:
223
+ raise _invalid(f"invalid regular expression: {exc}") from exc
224
+ if _catastrophic(parsed):
225
+ raise _invalid(
226
+ "regular expression repeats a repeat (like (a+)+), which can take"
227
+ " exponential time; rewrite it without nested unbounded repeats"
228
+ )
229
+
230
+
231
+ def _catastrophic(parsed: Any) -> bool:
232
+ """True for an unbounded repeat whose body repeats unboundedly with nothing to separate."""
233
+ consts = importlib.import_module("re._constants")
234
+ repeats = (consts.MAX_REPEAT, consts.MIN_REPEAT)
235
+
236
+ def unbounded(op: Any, av: Any) -> bool:
237
+ return bool(op in repeats and av[1] >= _UNBOUNDED)
238
+
239
+ def flat(items: Any) -> list[tuple[Any, Any]]:
240
+ out: list[tuple[Any, Any]] = []
241
+ for op, av in items:
242
+ if op == consts.SUBPATTERN:
243
+ out += flat(av[3])
244
+ else:
245
+ out.append((op, av))
246
+ return out
247
+
248
+ def repeats_itself(body: list[tuple[Any, Any]]) -> bool:
249
+ for i, (op, av) in enumerate(body):
250
+ if unbounded(op, av):
251
+ rest = body[:i] + body[i + 1 :]
252
+ if all(r_op in repeats and r_av[0] == 0 for r_op, r_av in rest):
253
+ return True
254
+ return False
255
+
256
+ def walk(items: Any) -> bool:
257
+ for op, av in items:
258
+ if op in repeats:
259
+ if unbounded(op, av) and repeats_itself(flat(av[2])):
260
+ return True
261
+ if walk(av[2]):
262
+ return True
263
+ elif op == consts.SUBPATTERN:
264
+ if walk(av[3]):
265
+ return True
266
+ elif op == consts.BRANCH and any(walk(branch) for branch in av[1]):
267
+ return True
268
+ return False
269
+
270
+ return walk(parsed)
271
+
272
+
273
+ # -- scanning many paragraphs -------------------------------------------------------------
274
+
275
+ Match = tuple[int, int, tuple[str, ...]] # (start, end, capture groups)
276
+ Item = tuple[int, str] # (paragraph number, visible text)
277
+
278
+
279
+ class LocalScan:
280
+ """Search paragraphs in this process, checking the deadline between paragraphs.
281
+
282
+ Iterating yields ``(number, spans)`` for each paragraph that has matches, in order.
283
+ ``stopped_at`` is the first paragraph left unsearched when the deadline passed
284
+ (at least one paragraph is always searched), else ``None``.
285
+ """
286
+
287
+ def __init__(self, matcher: Matcher, items: Sequence[Item], deadline: float) -> None:
288
+ self._matcher = matcher
289
+ self._items = items
290
+ self._deadline = deadline
291
+ self.stopped_at: int | None = None
292
+
293
+ def __iter__(self) -> Generator[tuple[int, list[Span]], None, None]:
294
+ for done, (number, text) in enumerate(self._items):
295
+ if done and time.monotonic() > self._deadline:
296
+ self.stopped_at = number
297
+ return
298
+ found = list(self._matcher.spans(text)) if text else []
299
+ if found:
300
+ yield number, found
301
+
302
+
303
+ def _worker(
304
+ options: SearchOptions, items: Sequence[Item], progress: Any, conn: Any, detail: bool = False
305
+ ) -> None:
306
+ """Child process body: stream matches back, publishing the paragraph being searched."""
307
+ try:
308
+ matcher = Matcher(options)
309
+ for number, text in items:
310
+ progress.value = number
311
+ found: list[Any] = []
312
+ if text:
313
+ found = list(matcher.matches(text) if detail else matcher.spans(text))
314
+ if found:
315
+ conn.send((number, found))
316
+ conn.send(None)
317
+ except BaseException as exc: # reported to the parent, which raises INTERNAL
318
+ conn.send(("error", f"{type(exc).__name__}: {exc}"))
319
+ finally:
320
+ conn.close()
321
+
322
+
323
+ class IsolatedScan:
324
+ """Like :class:`LocalScan`, but in a child process killed at the deadline.
325
+
326
+ Python's regex engine cannot be interrupted, so a pathological pattern on a short
327
+ paragraph would otherwise hang the caller. The child (``spawn`` context, so it
328
+ behaves the same on Windows) publishes the paragraph it is in through shared
329
+ memory; on timeout it is terminated, matches it already sent are kept, and
330
+ ``stopped_at`` is the paragraph it was stuck in. Close the generator to stop early.
331
+ A script that calls this (through ``find``) must guard its entry point with
332
+ ``if __name__ == "__main__":``, as ``spawn`` re-imports the main module.
333
+ """
334
+
335
+ def __init__(
336
+ self,
337
+ options: SearchOptions,
338
+ items: Sequence[Item],
339
+ deadline: float,
340
+ detail: bool = False,
341
+ ) -> None:
342
+ self._detail = detail
343
+ self._options = options
344
+ self._items = list(items)
345
+ self._deadline = deadline
346
+ self.stopped_at: int | None = None
347
+
348
+ def __iter__(self) -> Generator[tuple[int, list[Any]], None, None]:
349
+ if not self._items:
350
+ return
351
+ ctx = multiprocessing.get_context("spawn")
352
+ progress = ctx.Value("q", self._items[0][0], lock=False)
353
+ recv, send = ctx.Pipe(duplex=False)
354
+ proc = ctx.Process(
355
+ target=_worker,
356
+ args=(self._options, self._items, progress, send, self._detail),
357
+ daemon=True,
358
+ )
359
+ try:
360
+ proc.start()
361
+ send.close()
362
+ last = 0 # number of the last paragraph yielded
363
+ while True:
364
+ remaining = self._deadline - time.monotonic()
365
+ if remaining <= 0 or not recv.poll(remaining):
366
+ break
367
+ try:
368
+ message = recv.recv()
369
+ except (EOFError, OSError) as exc:
370
+ raise DocControlError(ErrorCode.INTERNAL, "regex worker ended early") from exc
371
+ if message is None:
372
+ return
373
+ if message[0] == "error":
374
+ raise DocControlError(ErrorCode.INTERNAL, f"regex worker failed: {message[1]}")
375
+ last = message[0]
376
+ yield message
377
+ proc.terminate()
378
+ proc.join(5)
379
+ late: list[Any] = [] # what the child sent before it was killed
380
+ finished = False
381
+ while True:
382
+ try:
383
+ if not recv.poll(0):
384
+ break
385
+ message = recv.recv()
386
+ except (EOFError, OSError):
387
+ break
388
+ if message is None:
389
+ finished = True
390
+ elif message[0] != "error":
391
+ late.append(message)
392
+ if not finished:
393
+ self.stopped_at = max([int(progress.value), last + 1, *(m[0] + 1 for m in late)])
394
+ yield from late
395
+ finally:
396
+ if proc.is_alive():
397
+ proc.terminate()
398
+ proc.join(5)
399
+ if proc.is_alive():
400
+ proc.kill()
401
+ proc.join()
402
+ send.close()
403
+ recv.close()