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,370 @@
1
+ """``term.rename``: rename a term everywhere, as one tracked batch of ``text.replace`` edits.
2
+
3
+ The body text is searched with the same matcher as ``find`` (accent and case folding,
4
+ whole word), every match becomes one ``text.replace`` and all of them go through
5
+ :func:`doc_control.ops.tracked.apply_edits` in a single verified write. Matches that
6
+ span several runs are replaced like any other range. ``dry_run`` plans everything and
7
+ writes nothing; the result carries counts and a sample of the changes.
8
+
9
+ Options
10
+ ``whole_word`` (default true): no letter, digit or mark on either side.
11
+ ``case_sensitive`` (default false): when true only the exact spelling matches and
12
+ ``to`` is used as written.
13
+ ``preserve_case`` (default true, ignored when case sensitive): a match written in
14
+ capitals gets ``to`` in capitals (``LUCÍA`` becomes ``ELENA``), one in lower case gets
15
+ it in lower case, one with a capital first letter gets ``to`` with a capital first
16
+ letter, the exact spelling of ``from`` gets ``to`` as written, any other mix gets
17
+ ``to`` as written.
18
+ ``ignore_accents`` (default false): ``Lucia`` also matches ``Lucía``.
19
+ ``inflections``: suffixes that follow the term. A string ``"s"`` renames ``Lucías``
20
+ to ``Elenas``; an object ``{"from": "o", "to": "a"}`` renames ``Lucíao`` to
21
+ ``Elenaa``. Each form is matched as a whole word too. Where forms overlap, the
22
+ longest wins.
23
+
24
+ Deferred: a nested ``{"op": "term.rename"}`` inside ``batch`` (it needs the document to
25
+ expand); ``term.rename`` is its own write operation for now.
26
+
27
+ Not renamed, never corrupted
28
+ Hidden text (runs with ``w:vanish``) and field instructions are not part of the
29
+ visible text, so they are not searched and stay as they are. A match with a character
30
+ in a field result, a hyperlink, content control or other structure the editor does not
31
+ cut through is skipped and counted (``skipped_unsupported``), as is a match that would
32
+ split a character from its combining marks (``skipped_boundary``). Everything else in
33
+ the paragraph is still renamed.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from doc_control.envelope import Reply
43
+ from doc_control.errors import DocControlError, ErrorCode
44
+ from doc_control.model.document import Document
45
+ from doc_control.model.index import DocumentIndex
46
+ from doc_control.model.search import Matcher, SearchOptions
47
+ from doc_control.ooxml.revisions import (
48
+ DEFAULT_AUTHOR,
49
+ ParagraphEditor,
50
+ RevisionContext,
51
+ format_date,
52
+ used_ids,
53
+ )
54
+ from doc_control.ooxml.zipio import Package
55
+ from doc_control.ops.tracked import ReplaceText, TrackedResult, apply_edits, boundary_ok
56
+ from doc_control.registry import operation
57
+
58
+ DEFAULT_PREVIEW = 20
59
+ MAX_PREVIEW = 200
60
+ MAX_SUFFIX = 50
61
+ CONTEXT = 30
62
+
63
+ _SCHEMA: dict[str, Any] = {
64
+ "type": "object",
65
+ "properties": {
66
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
67
+ "from": {"type": "string", "minLength": 1, "description": "Term to rename."},
68
+ "to": {"type": "string", "minLength": 1, "description": "New term."},
69
+ "whole_word": {"type": "boolean", "default": True, "description": "Whole words only."},
70
+ "case_sensitive": {
71
+ "type": "boolean",
72
+ "default": False,
73
+ "description": "Exact spelling only; `to` is used as written.",
74
+ },
75
+ "preserve_case": {
76
+ "type": "boolean",
77
+ "default": True,
78
+ "description": "LUCÍA becomes ELENA, lucía becomes elena, Lucía becomes Elena.",
79
+ },
80
+ "ignore_accents": {
81
+ "type": "boolean",
82
+ "default": False,
83
+ "description": "`Lucia` also matches `Lucía`.",
84
+ },
85
+ "inflections": {
86
+ "type": "array",
87
+ "description": (
88
+ 'Suffixes that follow the term: "s" renames Lucías to Elenas; '
89
+ '{"from": "o", "to": "a"} renames Lucíao to Elenaa.'
90
+ ),
91
+ "items": {"type": ["string", "object"]},
92
+ },
93
+ "expect": {
94
+ "type": "object",
95
+ "properties": {"sha256": {"type": "string", "minLength": 1}},
96
+ "additionalProperties": False,
97
+ },
98
+ "dry_run": {"type": "boolean", "description": "Plan and preview only; write nothing."},
99
+ "preview_limit": {
100
+ "type": "integer",
101
+ "minimum": 0,
102
+ "maximum": MAX_PREVIEW,
103
+ "default": DEFAULT_PREVIEW,
104
+ "description": "Most changes echoed back in `preview`.",
105
+ },
106
+ "author": {"type": "string", "minLength": 1, "description": "Revision author."},
107
+ "tag": {"type": "string", "minLength": 1, "description": "Appended to the author."},
108
+ },
109
+ "required": ["doc", "from", "to"],
110
+ "additionalProperties": False,
111
+ }
112
+
113
+
114
+ @dataclass
115
+ class Counts:
116
+ matches: int = 0
117
+ replaced: int = 0
118
+ unchanged: int = 0
119
+ paragraphs: int = 0
120
+ skipped_unsupported: int = 0
121
+ skipped_boundary: int = 0
122
+
123
+ def to_dict(self) -> dict[str, int]:
124
+ return dict(vars(self))
125
+
126
+
127
+ @dataclass
128
+ class _Change:
129
+ at: str
130
+ paragraph: int
131
+ start: int
132
+ end: int
133
+ before: str
134
+ after: str
135
+ context: str
136
+
137
+
138
+ @dataclass
139
+ class RenamePlan:
140
+ edits: list[ReplaceText] = field(default_factory=list)
141
+ changes: list[_Change] = field(default_factory=list)
142
+ counts: Counts = field(default_factory=Counts)
143
+
144
+
145
+ def _bad(i: int, why: str) -> DocControlError:
146
+ return DocControlError(
147
+ ErrorCode.SCHEMA_INVALID, f"inflections[{i}]: {why}", path="$.inflections"
148
+ )
149
+
150
+
151
+ def _forms(inflections: list[Any]) -> list[tuple[str, str]]:
152
+ """``(suffix on the old term, suffix on the new term)``, the bare term first."""
153
+ forms = [("", "")]
154
+ for i, item in enumerate(inflections):
155
+ if isinstance(item, str):
156
+ old = new = item
157
+ elif isinstance(item, dict) and set(item) <= {"from", "to"} and "from" in item:
158
+ raw_old, raw_new = item["from"], item.get("to", item["from"])
159
+ if not isinstance(raw_old, str) or not isinstance(raw_new, str):
160
+ raise _bad(i, "'from' and 'to' must be strings")
161
+ old, new = raw_old, raw_new
162
+ else:
163
+ raise _bad(i, 'use a suffix string or {"from": suffix, "to": suffix}')
164
+ if not old or len(old) > MAX_SUFFIX or len(new) > MAX_SUFFIX:
165
+ raise _bad(i, f"a suffix must have 1 to {MAX_SUFFIX} characters")
166
+ if (old, new) not in forms:
167
+ forms.append((old, new))
168
+ return forms
169
+
170
+
171
+ def _cased(c: str) -> bool:
172
+ return c.lower() != c.upper()
173
+
174
+
175
+ def match_case(matched: str, exact: str, target: str) -> str:
176
+ """``target`` dressed in the capitalisation of ``matched``.
177
+
178
+ The exact spelling of the old term, and any odd mix, give ``target`` as written. All
179
+ capitals (two letters or more) give ``target.upper()``. Lower case gives
180
+ ``target.lower()`` unless ``target`` has a capital after its first letter (``McDonald``).
181
+ A capital first letter followed by lower case capitalises the first letter of
182
+ ``target``, unless ``target`` is not all lower case or title case (``iPhone``).
183
+ """
184
+ if matched == exact:
185
+ return target
186
+ letters = [c for c in matched if _cased(c)]
187
+ if not letters:
188
+ return target
189
+ if all(c == c.lower() for c in letters):
190
+ inner = [c for c in target if _cased(c)][1:]
191
+ return target if any(c != c.lower() for c in inner) else target.lower()
192
+ if len(letters) > 1 and all(c == c.upper() for c in letters):
193
+ return target.upper()
194
+ if letters[0] == letters[0].upper() and all(c == c.lower() for c in letters[1:]):
195
+ shape = [c for c in target if _cased(c)]
196
+ plain = all(c == c.lower() for c in shape[1:]) # all lower case or title case
197
+ i = next((i for i, c in enumerate(target) if _cased(c)), None)
198
+ if i is None or not plain:
199
+ return target
200
+ return target[:i] + target[i].upper() + target[i + 1 :]
201
+ return target
202
+
203
+
204
+ def plan_rename(
205
+ index: DocumentIndex,
206
+ old: str,
207
+ new: str,
208
+ *,
209
+ whole_word: bool = True,
210
+ case_sensitive: bool = False,
211
+ preserve_case: bool = True,
212
+ ignore_accents: bool = False,
213
+ inflections: list[Any] | None = None,
214
+ ) -> RenamePlan:
215
+ """Every match of ``old`` in the body as a ``text.replace``; nothing is written."""
216
+ matchers = [
217
+ (
218
+ Matcher(
219
+ SearchOptions(
220
+ old + sfx_old,
221
+ ignore_case=not case_sensitive,
222
+ ignore_accents=ignore_accents,
223
+ whole_word=whole_word,
224
+ )
225
+ ),
226
+ old + sfx_old,
227
+ new + sfx_new,
228
+ )
229
+ for sfx_old, sfx_new in _forms(inflections or [])
230
+ ]
231
+ dress = preserve_case and not case_sensitive
232
+ plan = RenamePlan()
233
+ counts = plan.counts
234
+ for para in index.paragraphs:
235
+ text = para.text
236
+ if not text:
237
+ continue
238
+ found: list[tuple[int, int, str, str]] = []
239
+ for matcher, exact, target in matchers:
240
+ found += [(s, e, exact, target) for s, e in matcher.spans(text)]
241
+ if not found:
242
+ continue
243
+ found.sort(key=lambda m: (m[0], -m[1]))
244
+ in_field = any(p.in_field for p in para.pieces)
245
+ edge = 0
246
+ counted = False
247
+ for s, e, exact, target in found:
248
+ if s < edge:
249
+ continue # inside a longer form already taken
250
+ edge = e
251
+ counts.matches += 1
252
+ if not (boundary_ok(text, s) and boundary_ok(text, e)):
253
+ counts.skipped_boundary += 1
254
+ continue
255
+ if in_field and any(p.in_field for p in para.pieces_in(s, e)):
256
+ counts.skipped_unsupported += 1
257
+ continue
258
+ matched = text[s:e]
259
+ after = match_case(matched, exact, target) if dress else target
260
+ if after == matched:
261
+ counts.unchanged += 1
262
+ continue
263
+ at = f"{para.address}:{s}-{e}"
264
+ lo, hi = max(0, s - CONTEXT), min(len(text), e + CONTEXT)
265
+ context = ("…" if lo else "") + text[lo:hi] + ("…" if hi < len(text) else "")
266
+ plan.edits.append(ReplaceText(at, after))
267
+ plan.changes.append(_Change(at, para.number, s, e, matched, after, context))
268
+ counts.replaced += 1
269
+ if not counted:
270
+ counts.paragraphs += 1
271
+ counted = True
272
+ return plan
273
+
274
+
275
+ def _drop_unsupported(pkg: Package, index: DocumentIndex, plan: RenamePlan) -> RenamePlan:
276
+ """Keep only the edits the editor can make (each tried on a throwaway copy)."""
277
+ ctx = RevisionContext(used_ids(pkg), "check", format_date(None))
278
+ kept = RenamePlan(counts=plan.counts)
279
+ for edit, change in zip(plan.edits, plan.changes, strict=True):
280
+ para = index.paragraph(change.paragraph)
281
+ elem = index.spans.parse_paragraph(para.span_index)
282
+ try:
283
+ ParagraphEditor(elem, para, ctx).replace(change.start, change.end, change.after)
284
+ except DocControlError as err:
285
+ if err.code is not ErrorCode.UNSUPPORTED_CONTENT:
286
+ raise
287
+ plan.counts.skipped_unsupported += 1
288
+ plan.counts.replaced -= 1
289
+ continue
290
+ kept.edits.append(edit)
291
+ kept.changes.append(change)
292
+ kept.counts.paragraphs = len({c.paragraph for c in kept.changes})
293
+ return kept
294
+
295
+
296
+ @operation(
297
+ "term.rename",
298
+ "write",
299
+ "Rename a term everywhere as one tracked batch: whole word, case, preserve case and "
300
+ "inflections; dry_run previews with counts.",
301
+ _SCHEMA,
302
+ )
303
+ def term_rename(request: dict[str, Any]) -> Reply:
304
+ path = Path(request["doc"])
305
+ old, new = request["from"], request["to"]
306
+ pkg = Package(path)
307
+ index = DocumentIndex.from_package(pkg)
308
+ plan = plan_rename(
309
+ index,
310
+ old,
311
+ new,
312
+ whole_word=request.get("whole_word", True),
313
+ case_sensitive=request.get("case_sensitive", False),
314
+ preserve_case=request.get("preserve_case", True),
315
+ ignore_accents=request.get("ignore_accents", False),
316
+ inflections=request.get("inflections"),
317
+ )
318
+ dry_run = request.get("dry_run", False)
319
+ expect = request.get("expect", {}).get("sha256") or pkg.sha256
320
+ author = request.get("author", DEFAULT_AUTHOR)
321
+ tag = request.get("tag")
322
+
323
+ def apply(edits: list[ReplaceText]) -> TrackedResult:
324
+ return apply_edits(
325
+ path, edits, expect_sha256=expect, author=author, tag=tag, dry_run=dry_run
326
+ )
327
+
328
+ applied: TrackedResult | None = None
329
+ if plan.edits:
330
+ try:
331
+ applied = apply(plan.edits)
332
+ except DocControlError as err:
333
+ if err.code is not ErrorCode.UNSUPPORTED_CONTENT:
334
+ raise
335
+ plan = _drop_unsupported(pkg, index, plan)
336
+ if plan.edits:
337
+ applied = apply(plan.edits)
338
+ counts = plan.counts
339
+ limit = request.get("preview_limit", DEFAULT_PREVIEW)
340
+ result: dict[str, Any] = {
341
+ "from": old,
342
+ "to": new,
343
+ "dry_run": dry_run,
344
+ "counts": counts.to_dict(),
345
+ "preview": [
346
+ {"at": c.at, "before": c.before, "after": c.after, "context": c.context}
347
+ for c in plan.changes[:limit]
348
+ ],
349
+ }
350
+ warnings: list[str] = []
351
+ if applied is not None:
352
+ body = applied.to_dict()
353
+ keys = ("address_map", "footprint", "revision_ids", "author", "date", "sha256", "backup")
354
+ for key in keys:
355
+ if key in body:
356
+ result[key] = body[key]
357
+ warnings += applied.warnings
358
+ elif not dry_run:
359
+ result["sha256"] = pkg.sha256
360
+ if counts.skipped_unsupported:
361
+ warnings.append(
362
+ f"{counts.skipped_unsupported} match(es) inside a field, link or other structure"
363
+ " were left unchanged"
364
+ )
365
+ if counts.skipped_boundary:
366
+ warnings.append(
367
+ f"{counts.skipped_boundary} match(es) would split a character from its marks"
368
+ " and were left unchanged"
369
+ )
370
+ return Reply(result, doc=Document(str(path)).envelope_doc(), warnings=warnings)