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
doc_control/py.typed ADDED
File without changes
@@ -0,0 +1,209 @@
1
+ """Run a validated recipe over paragraph texts and produce a batch proposal.
2
+
3
+ The engine reads nothing but the texts it is given and returns operation dicts for
4
+ ``batch``; it writes nothing. Every address in the proposal refers to the document as
5
+ read, so the result is a valid batch. With ``repeat_until_stable`` the recipe is run again
6
+ on the text its own replacements would leave, until a pass changes nothing or
7
+ ``max_passes`` is reached. A paragraph that needed more than one pass is proposed as one
8
+ ``text.replace`` over the smallest range that differs between before and after.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import time
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from doc_control.model.address import Address
20
+ from doc_control.model.search import IsolatedScan, Match, Matcher, SearchOptions
21
+ from doc_control.ops.tracked import boundary_ok
22
+ from doc_control.recipes.schema import DEFAULT_PASSES, SUBSTITUTION, search_options
23
+
24
+ MAX_OPS = 2000
25
+ TIME_BUDGET_SECONDS = 10.0
26
+
27
+
28
+ @dataclass
29
+ class Proposal:
30
+ ops: list[dict[str, Any]]
31
+ passes: int
32
+ stable: bool
33
+ matches: int
34
+ warnings: list[str] = field(default_factory=list)
35
+
36
+
37
+ def _substitute(text: str, match: str, para: str, groups: Sequence[str]) -> str:
38
+ def one(m: Any) -> str:
39
+ ref = m.group(1)
40
+ if ref == "$":
41
+ return "$"
42
+ if ref == "match":
43
+ return match
44
+ if ref == "para":
45
+ return para
46
+ return groups[int(ref) - 1]
47
+
48
+ return SUBSTITUTION.sub(one, text)
49
+
50
+
51
+ def _smallest_edit(before: str, after: str) -> tuple[int, int, str]:
52
+ """``(start, end, new)``: replace ``before[start:end]`` with ``new``."""
53
+ limit = min(len(before), len(after))
54
+ head = 0
55
+ while head < limit and before[head] == after[head]:
56
+ head += 1
57
+ while head and not (boundary_ok(before, head) and boundary_ok(after, head)):
58
+ head -= 1
59
+ tail = 0
60
+ while tail < limit - head and before[-1 - tail] == after[-1 - tail]:
61
+ tail += 1
62
+ while tail and not (
63
+ boundary_ok(before, len(before) - tail) and boundary_ok(after, len(after) - tail)
64
+ ):
65
+ tail -= 1
66
+ return head, len(before) - tail, after[head : len(after) - tail]
67
+
68
+
69
+ def _scan(
70
+ options: SearchOptions, matcher: Matcher, items: list[tuple[int, str]], deadline: float
71
+ ) -> tuple[list[tuple[int, list[Match]]], int | None]:
72
+ """Matches per paragraph, and the paragraph the time budget stopped at (or ``None``).
73
+
74
+ A regex runs in a child process that is killed at the deadline (Python's regex engine
75
+ cannot be interrupted, and the static screen cannot catch every pattern that
76
+ backtracks badly). A literal cannot backtrack and is searched here.
77
+ """
78
+ if options.regex:
79
+ scan = IsolatedScan(options, items, deadline, detail=True)
80
+ with contextlib.closing(scan.__iter__()) as found:
81
+ return [(number, list(spans)) for number, spans in found], scan.stopped_at
82
+ out: list[tuple[int, list[Match]]] = []
83
+ for done, (number, text) in enumerate(items):
84
+ if done and time.monotonic() > deadline:
85
+ return out, number
86
+ spans = list(matcher.matches(text))
87
+ if spans:
88
+ out.append((number, spans))
89
+ return out, None
90
+
91
+
92
+ def propose(
93
+ recipe: dict[str, Any],
94
+ paragraphs: Sequence[tuple[Address, str]],
95
+ *,
96
+ deadline: float | None = None,
97
+ ) -> Proposal:
98
+ """The batch proposal of ``recipe`` (already validated) over ``paragraphs``."""
99
+ options = search_options(recipe)
100
+ matcher = Matcher(options)
101
+ steps: list[dict[str, Any]] = recipe["propose"]
102
+ repeat = recipe.get("repeat_until_stable", False)
103
+ max_passes = recipe.get("max_passes", DEFAULT_PASSES) if repeat else 1
104
+ deadline = time.monotonic() + TIME_BUDGET_SECONDS if deadline is None else deadline
105
+
106
+ work = {a.number: t for a, t in paragraphs}
107
+ original = dict(work)
108
+ address = {a.number: a for a, _ in paragraphs}
109
+ direct: dict[int, list[dict[str, Any]]] = {} # pass-1 text edits, kept when no 2nd pass
110
+ others: list[dict[str, Any]] = [] # every other operation, from the original text only
111
+ seen: set[tuple[str, str]] = set()
112
+ redone: set[int] = set() # paragraphs whose text changed in a pass after the first
113
+ deleted: set[int] = set()
114
+ warnings: list[str] = []
115
+ total = 0
116
+ passes = 0
117
+ stable = False
118
+ capped = False
119
+ to_scan = sorted(n for n, t in work.items() if t)
120
+
121
+ for passno in range(1, max_passes + 1):
122
+ passes = passno
123
+ found, stopped = _scan(options, matcher, [(n, work[n]) for n in to_scan], deadline)
124
+ if stopped is not None:
125
+ warnings.append(f"TIME_BUDGET: stopped at p{stopped}; the proposal is partial")
126
+ capped = True
127
+ changed: list[int] = []
128
+ for number, spans in found:
129
+ if number in deleted:
130
+ continue
131
+ text = work[number]
132
+ addr = address[number]
133
+ edits: list[tuple[int, int, str]] = []
134
+ for start, end, groups in spans:
135
+ if passno == 1:
136
+ total += 1
137
+ matched = text[start:end]
138
+ for step in steps:
139
+ kind = step["op"]
140
+ if kind != "text.replace" and passno > 1:
141
+ continue # other operations are decided on the text as read
142
+ op = _instantiate(step, addr, start, end, matched, text, groups)
143
+ if kind == "text.replace":
144
+ lo, hi = (0, len(text)) if step["at"] == "$para" else (start, end)
145
+ if text[lo:hi] == op["text"]:
146
+ continue # already what the recipe wants
147
+ edits.append((lo, hi, op["text"]))
148
+ if passno == 1:
149
+ direct.setdefault(number, []).append(op)
150
+ continue
151
+ if kind == "para.delete":
152
+ deleted.add(number)
153
+ if (kind, op["at"]) not in seen:
154
+ seen.add((kind, op["at"]))
155
+ others.append(op)
156
+ if number in deleted:
157
+ continue
158
+ new = text
159
+ for start, end, replacement in reversed(edits):
160
+ new = new[:start] + replacement + new[end:]
161
+ if new != text:
162
+ changed.append(number)
163
+ work[number] = new
164
+ if passno > 1:
165
+ redone.add(number)
166
+ if len(others) + sum(map(len, direct.values())) > MAX_OPS:
167
+ warnings.append(f"OPS_CAP: more than {MAX_OPS} operations; narrow the recipe")
168
+ capped = True
169
+ if capped:
170
+ break
171
+ if not changed:
172
+ stable = True
173
+ break
174
+ to_scan = [n for n in changed if work[n]]
175
+ if not stable and not capped and repeat:
176
+ warnings.append(f"NOT_STABLE: still changing after {max_passes} passes")
177
+ if capped:
178
+ stable = False
179
+
180
+ ops: list[dict[str, Any]] = []
181
+ for number in sorted(set(direct) | redone):
182
+ if number in deleted:
183
+ continue
184
+ if number in redone:
185
+ start, end, new = _smallest_edit(original[number], work[number])
186
+ if (start, end, new) != (0, 0, ""):
187
+ ops.append(
188
+ {"op": "text.replace", "at": f"{address[number]}:{start}-{end}", "text": new}
189
+ )
190
+ else:
191
+ ops.extend(direct[number])
192
+ ops.extend(others)
193
+ return Proposal(ops, passes, stable, total, warnings)
194
+
195
+
196
+ def _instantiate(
197
+ step: dict[str, Any],
198
+ addr: Address,
199
+ start: int,
200
+ end: int,
201
+ matched: str,
202
+ para: str,
203
+ groups: Sequence[str],
204
+ ) -> dict[str, Any]:
205
+ out = {k: v for k, v in step.items() if k not in ("op", "at")}
206
+ if "text" in out:
207
+ out["text"] = _substitute(out["text"], matched, para, groups)
208
+ at = str(addr) if step["at"] == "$para" else f"{addr}:{start}-{end}"
209
+ return {"op": step["op"], "at": at, **out}
@@ -0,0 +1,140 @@
1
+ """The strict schema of a declarative recipe, and its validation.
2
+
3
+ A recipe is pure data. Every object, at every level, rejects keys the schema does not
4
+ list (``RECIPE_INVALID``), so a recipe has no way to name code, files or the network. The
5
+ only things a recipe can ask for are the batch operations in :data:`PROPOSABLE`, aimed at
6
+ ``$match`` (the matched range) or ``$para`` (its paragraph). Text may use ``$match``,
7
+ ``$para`` and ``$1``..``$9`` (``$$`` is a literal dollar sign); nothing is evaluated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any
14
+
15
+ from doc_control.errors import DocControlError, ErrorCode
16
+ from doc_control.model.search import Matcher, SearchOptions
17
+ from doc_control.ops.comments import COMMENT_SCHEMAS, comment_from_dict
18
+ from doc_control.ops.tracked import OPERATION_SCHEMAS, edit_from_dict
19
+ from doc_control.schema import validate
20
+
21
+ RECIPE_VERSION = 1
22
+ NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
23
+ SUBSTITUTION = re.compile(r"\$(\$|match|para|[1-9])")
24
+ PLACEHOLDER = "p1#00000000" # stands in for `at` when checking an operation's other fields
25
+ TARGETS = ("$match", "$para")
26
+ PROPOSABLE = ("text.replace", "format.apply", "style.apply", "para.delete", "comment.add")
27
+ MAX_PROPOSE = 8
28
+ MAX_PASSES = 10
29
+ DEFAULT_PASSES = 4
30
+
31
+ _FIND: dict[str, Any] = {
32
+ "type": "object",
33
+ "properties": {
34
+ "regex": {"type": "string", "minLength": 1},
35
+ "literal": {"type": "string", "minLength": 1},
36
+ "ignore_case": {"type": "boolean"},
37
+ "ignore_accents": {"type": "boolean"},
38
+ "whole_word": {"type": "boolean"},
39
+ "scope": {"enum": ["paragraph"]},
40
+ },
41
+ "additionalProperties": False,
42
+ }
43
+
44
+ RECIPE_SCHEMA: dict[str, Any] = {
45
+ "type": "object",
46
+ "properties": {
47
+ "recipe": {"type": "string", "minLength": 1},
48
+ "version": {"enum": [RECIPE_VERSION]},
49
+ "description": {"type": "string"},
50
+ "match": {
51
+ "type": "object",
52
+ "properties": {"find": _FIND},
53
+ "required": ["find"],
54
+ "additionalProperties": False,
55
+ },
56
+ "propose": {"type": "array", "items": {"type": "object"}},
57
+ "repeat_until_stable": {"type": "boolean"},
58
+ "max_passes": {"type": "integer", "minimum": 1, "maximum": MAX_PASSES},
59
+ },
60
+ "required": ["recipe", "version", "match", "propose"],
61
+ "additionalProperties": False,
62
+ }
63
+
64
+
65
+ def _invalid(message: str, **details: Any) -> DocControlError:
66
+ return DocControlError(ErrorCode.RECIPE_INVALID, message, **details)
67
+
68
+
69
+ def _checked(value: Any, schema: dict[str, Any], path: str) -> None:
70
+ try:
71
+ validate(value, schema, path)
72
+ except DocControlError as err:
73
+ raise _invalid(err.message, **err.details) from err
74
+
75
+
76
+ def search_options(recipe: dict[str, Any]) -> SearchOptions:
77
+ find = recipe["match"]["find"]
78
+ return SearchOptions(
79
+ find.get("regex", find.get("literal")),
80
+ regex="regex" in find,
81
+ ignore_case=find.get("ignore_case", False),
82
+ ignore_accents=find.get("ignore_accents", False),
83
+ whole_word=find.get("whole_word", False),
84
+ )
85
+
86
+
87
+ def validate_recipe(raw: Any) -> dict[str, Any]:
88
+ """Return ``raw`` if it is a well-formed recipe, else raise ``RECIPE_INVALID``."""
89
+ _checked(raw, RECIPE_SCHEMA, "$")
90
+ if NAME.fullmatch(raw["recipe"]) is None:
91
+ raise _invalid("$.recipe: use lower-case letters, digits and hyphens (at most 64)")
92
+ find = raw["match"]["find"]
93
+ if ("regex" in find) == ("literal" in find):
94
+ raise _invalid("$.match.find: give exactly one of `regex` and `literal`")
95
+ options = search_options(raw)
96
+ try:
97
+ matcher = Matcher(options)
98
+ except DocControlError as err:
99
+ raise _invalid(f"$.match.find: {err.message}") from err
100
+ groups = matcher.pattern.groups if options.regex else 0
101
+ steps = raw["propose"]
102
+ if not 1 <= len(steps) <= MAX_PROPOSE:
103
+ raise _invalid(f"$.propose: give 1 to {MAX_PROPOSE} operations")
104
+ repeat = raw.get("repeat_until_stable", False)
105
+ if "max_passes" in raw and not repeat:
106
+ raise _invalid("$.max_passes: only meaningful with `repeat_until_stable`")
107
+ kinds = [
108
+ _check_step(step, f"$.propose[{i}]", groups, options, repeat)
109
+ for i, step in enumerate(steps)
110
+ ]
111
+ if kinds.count("text.replace") > 1:
112
+ raise _invalid("$.propose: at most one text.replace")
113
+ if "para.delete" in kinds and len(kinds) > 1:
114
+ raise _invalid("$.propose: para.delete cannot be combined with other operations")
115
+ return dict(raw)
116
+
117
+
118
+ def _check_step(step: Any, path: str, groups: int, options: SearchOptions, repeat: bool) -> str:
119
+ if not isinstance(step, dict) or step.get("op") not in PROPOSABLE:
120
+ raise _invalid(f"{path}.op: must be one of {list(PROPOSABLE)}")
121
+ kind: str = step["op"]
122
+ _checked(step, OPERATION_SCHEMAS.get(kind) or COMMENT_SCHEMAS[kind], path)
123
+ at = step["at"]
124
+ if at not in TARGETS or (kind == "para.delete" and at != "$para"):
125
+ allowed = "$para" if kind == "para.delete" else "$match or $para"
126
+ raise _invalid(f"{path}.at: must be {allowed}")
127
+ if repeat and at == "$match" and kind != "text.replace":
128
+ raise _invalid(f"{path}.at: with `repeat_until_stable` only text.replace may target $match")
129
+ for ref in SUBSTITUTION.findall(step.get("text", "")):
130
+ if ref.isdigit() and int(ref) > groups:
131
+ raise _invalid(f"{path}.text: ${ref} but the pattern has {groups} group(s)")
132
+ probe = {**step, "at": PLACEHOLDER}
133
+ try:
134
+ if kind == "comment.add":
135
+ comment_from_dict(probe)
136
+ else:
137
+ edit_from_dict(probe)
138
+ except DocControlError as err:
139
+ raise _invalid(f"{path}: {err.message}") from err
140
+ return kind
@@ -0,0 +1,96 @@
1
+ """Where recipes come from: bundled data files and the user store.
2
+
3
+ User recipes are ``<user home>/recipes/<name>.json`` (``DOC_CONTROL_HOME`` or the
4
+ platform default, see :func:`doc_control.safety.store.user_home`). Files are read as plain
5
+ JSON and validated against the strict schema; a bad file never stops the others and is
6
+ reported by :func:`catalogue`. A user recipe cannot take the name of a bundled one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from doc_control.errors import DocControlError, ErrorCode
18
+ from doc_control.recipes.schema import validate_recipe
19
+ from doc_control.safety.store import user_home
20
+
21
+ MAX_RECIPE_BYTES = 64 * 1024
22
+ BUNDLED_DIR = Path(__file__).parent / "bundled"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Catalogue:
27
+ recipes: dict[str, tuple[str, dict[str, Any]]] # name -> (source, recipe)
28
+ invalid: list[dict[str, str]]
29
+
30
+
31
+ def user_recipes_dir(env: Mapping[str, str] | None = None) -> Path:
32
+ return user_home(env) / "recipes"
33
+
34
+
35
+ def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
36
+ out: dict[str, Any] = {}
37
+ for key, value in pairs:
38
+ if key in out:
39
+ raise ValueError(f"duplicate key {key!r}")
40
+ out[key] = value
41
+ return out
42
+
43
+
44
+ def _no_constant(name: str) -> Any:
45
+ raise ValueError(f"{name} is not JSON")
46
+
47
+
48
+ def parse_recipe_file(path: Path) -> dict[str, Any]:
49
+ """Read and validate one recipe file; ``RECIPE_INVALID`` if it is not a valid recipe."""
50
+ try:
51
+ if path.stat().st_size > MAX_RECIPE_BYTES:
52
+ raise DocControlError(ErrorCode.RECIPE_INVALID, f"larger than {MAX_RECIPE_BYTES} bytes")
53
+ raw = json.loads(
54
+ path.read_text(encoding="utf-8"),
55
+ object_pairs_hook=_no_duplicates,
56
+ parse_constant=_no_constant,
57
+ )
58
+ except (OSError, ValueError) as exc: # JSONDecodeError and UnicodeDecodeError are ValueErrors
59
+ raise DocControlError(ErrorCode.RECIPE_INVALID, f"cannot read recipe: {exc}") from exc
60
+ recipe = validate_recipe(raw)
61
+ if path.stem != recipe["recipe"]:
62
+ raise DocControlError(
63
+ ErrorCode.RECIPE_INVALID, f"file name must be {recipe['recipe']}.json"
64
+ )
65
+ return recipe
66
+
67
+
68
+ def _load_dir(
69
+ directory: Path,
70
+ source: str,
71
+ found: dict[str, tuple[str, dict[str, Any]]],
72
+ bad: list[dict[str, str]],
73
+ ) -> None:
74
+ if not directory.is_dir():
75
+ return
76
+ for path in sorted(directory.glob("*.json")):
77
+ try:
78
+ if path.is_symlink():
79
+ raise DocControlError(ErrorCode.RECIPE_INVALID, "symbolic links are not followed")
80
+ recipe = parse_recipe_file(path)
81
+ if recipe["recipe"] in found:
82
+ raise DocControlError(
83
+ ErrorCode.RECIPE_INVALID, f"the name {recipe['recipe']} is already taken"
84
+ )
85
+ except DocControlError as err:
86
+ bad.append({"file": path.name, "source": source, "error": err.message})
87
+ continue
88
+ found[recipe["recipe"]] = (source, recipe)
89
+
90
+
91
+ def catalogue(env: Mapping[str, str] | None = None, bundled_dir: Path | None = None) -> Catalogue:
92
+ found: dict[str, tuple[str, dict[str, Any]]] = {}
93
+ bad: list[dict[str, str]] = []
94
+ _load_dir(BUNDLED_DIR if bundled_dir is None else bundled_dir, "bundled", found, bad)
95
+ _load_dir(user_recipes_dir(env), "user", found, bad)
96
+ return Catalogue(found, bad)
@@ -0,0 +1,76 @@
1
+ """Operation registry: one JSON Schema per operation, shared by CLI and MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ from doc_control.envelope import Reply
10
+ from doc_control.schema import check_schema, validate
11
+
12
+ # A handler returns its result dict, or a Reply to also fill `doc`, `warnings` and `next`.
13
+ Handler = Callable[[dict[str, Any]], dict[str, Any] | Reply]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Operation:
18
+ name: str
19
+ kind: Literal["read", "write"]
20
+ summary: str
21
+ schema: dict[str, Any]
22
+ handler: Handler
23
+
24
+ def run(self, request: dict[str, Any]) -> dict[str, Any] | Reply:
25
+ validate(request, self.schema)
26
+ return self.handler(request)
27
+
28
+
29
+ REGISTRY: dict[str, Operation] = {}
30
+
31
+
32
+ def operation(
33
+ name: str, kind: Literal["read", "write"], summary: str, schema: dict[str, Any]
34
+ ) -> Callable[[Handler], Handler]:
35
+ """Register a handler; the schema must be a well-formed object schema."""
36
+
37
+ def wrap(handler: Handler) -> Handler:
38
+ if name in REGISTRY:
39
+ raise ValueError(f"operation registered twice: {name}")
40
+ if schema.get("type") != "object":
41
+ raise ValueError(f"schema for {name} must be an object schema")
42
+ problems = check_schema(schema)
43
+ if problems:
44
+ raise ValueError(f"schema for {name} is malformed: {'; '.join(problems)}")
45
+ REGISTRY[name] = Operation(name, kind, summary, schema, handler)
46
+ return handler
47
+
48
+ return wrap
49
+
50
+
51
+ def load_operations() -> dict[str, Operation]:
52
+ """Import every operation module so their decorators run."""
53
+ from doc_control.ops import ( # noqa: F401
54
+ anchor,
55
+ batch,
56
+ comments,
57
+ compare,
58
+ diff,
59
+ dotx,
60
+ find,
61
+ history,
62
+ layout,
63
+ locate,
64
+ meta,
65
+ page,
66
+ read,
67
+ recipe,
68
+ rename,
69
+ revision,
70
+ segment,
71
+ styleset,
72
+ stylewrite,
73
+ text,
74
+ )
75
+
76
+ return REGISTRY
@@ -0,0 +1,38 @@
1
+ """Safety layer: checks and a backup that run before any write to a document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from doc_control.errors import DocControlError, ErrorCode
10
+ from doc_control.safety.lock import check_changed, check_unlocked
11
+ from doc_control.safety.store import backup
12
+
13
+ __all__ = ["guard_write"]
14
+
15
+
16
+ def guard_write(
17
+ doc_path: Path,
18
+ expect_sha256: str | None,
19
+ *,
20
+ now: datetime | None = None,
21
+ env: Mapping[str, str] | None = None,
22
+ ) -> Path:
23
+ """Run before any write: lock check, change check, then a backup.
24
+
25
+ Raises ``DOC_NOT_FOUND``, ``DOC_LOCKED`` or ``DOC_CHANGED_ON_DISK`` without
26
+ touching the document or creating a store; returns the backup path. The
27
+ backup re-checks ``expect_sha256`` against the bytes it copied, so a change
28
+ landing between the check and the copy is ``DOC_CHANGED_ON_DISK`` too, with
29
+ no backup left behind. A bad store config is ``SCHEMA_INVALID`` and a
30
+ corrupt store id ``INTERNAL``, both raised before anything is copied.
31
+ """
32
+ if not doc_path.is_file():
33
+ raise DocControlError(
34
+ ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
35
+ )
36
+ check_unlocked(doc_path)
37
+ check_changed(doc_path, expect_sha256)
38
+ return backup(doc_path, now=now, env=env, expect_sha256=expect_sha256)