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
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)
|
doc_control/registry.py
ADDED
|
@@ -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)
|