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