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,289 @@
1
+ """The `segment` read operation: chapters, scenes, sentences and dialogue lines.
2
+
3
+ ``unit`` picks the list. Chapters and scenes come from :mod:`doc_control.model.structure`
4
+ (stable ids, the signals behind each detection, ``overrides`` and ``profile``);
5
+ sentences and dialogue lines from :mod:`doc_control.model.segment`. Lists are capped at
6
+ about 8,000 bytes and continue with `next`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from doc_control.envelope import Reply
14
+ from doc_control.errors import DocControlError, ErrorCode
15
+ from doc_control.model.address import FINGERPRINT_LENGTH, Address
16
+ from doc_control.model.document import Document
17
+ from doc_control.model.segment import (
18
+ DEFAULT_LANGUAGE,
19
+ LANGUAGES,
20
+ dialogue_lines,
21
+ split_sentences,
22
+ )
23
+ from doc_control.model.structure import (
24
+ ACTIONS,
25
+ KINDS,
26
+ Override,
27
+ Profile,
28
+ Structure,
29
+ detect,
30
+ paragraphs_of,
31
+ )
32
+ from doc_control.ops.paging import PAGING_PROPERTIES, paged
33
+ from doc_control.registry import operation
34
+
35
+ _TEXT = 160
36
+ _SUPPRESSED_LIMIT = 20
37
+ _UNITS = ["chapters", "scenes", "sentences", "dialogue"]
38
+ _HEX = frozenset("0123456789abcdef")
39
+
40
+ PROFILE_PROPERTIES: dict[str, Any] = {
41
+ "min_chapter_words": {
42
+ "type": "integer",
43
+ "minimum": 0,
44
+ "description": (
45
+ "Words a chapter needs after its heading; a shorter one is not a chapter "
46
+ "(default 0). Part headings and forced chapters are exempt."
47
+ ),
48
+ },
49
+ "chapter_level": {
50
+ "type": "integer",
51
+ "minimum": 1,
52
+ "maximum": 9,
53
+ "description": "Deepest heading or outline level that starts a chapter (default 1).",
54
+ },
55
+ "scene_blank_lines": {
56
+ "type": "integer",
57
+ "minimum": 0,
58
+ "maximum": 50,
59
+ "description": "Empty paragraphs before text that start a scene; 0 = never (default).",
60
+ },
61
+ "heading_styles": {
62
+ "type": "boolean",
63
+ "description": "Heading styles and outline levels start chapters (default true).",
64
+ },
65
+ "chapter_patterns": {
66
+ "type": "boolean",
67
+ "description": "`Capítulo 3`, `Prólogo`, numerals start chapters (default true).",
68
+ },
69
+ "language": {
70
+ "type": "string",
71
+ "enum": list(LANGUAGES),
72
+ "description": "es (default) accepts a lone `I` as a numeral; others need more signals.",
73
+ },
74
+ "scene_markers": {
75
+ "type": "boolean",
76
+ "description": "Ornament paragraphs (`* * *`) start scenes (default true).",
77
+ },
78
+ }
79
+
80
+
81
+ OVERRIDES_SCHEMA: dict[str, Any] = {
82
+ "type": "array",
83
+ "description": "Force or deny a chapter or scene at paragraphs by fingerprint.",
84
+ "items": {
85
+ "type": "object",
86
+ "properties": {
87
+ "fp": {
88
+ "type": "string",
89
+ "minLength": FINGERPRINT_LENGTH,
90
+ "description": "Paragraph fingerprint, 8 lower-case hex digits.",
91
+ },
92
+ "kind": {"type": "string", "enum": list(KINDS)},
93
+ "action": {"type": "string", "enum": list(ACTIONS)},
94
+ "nth": {
95
+ "type": "integer",
96
+ "minimum": 1,
97
+ "description": "Only the nth paragraph with this fingerprint.",
98
+ },
99
+ },
100
+ "required": ["fp", "kind", "action"],
101
+ "additionalProperties": False,
102
+ },
103
+ }
104
+
105
+ PROFILE_SCHEMA: dict[str, Any] = {
106
+ "type": "object",
107
+ "description": "Detection settings for this document.",
108
+ "properties": PROFILE_PROPERTIES,
109
+ "additionalProperties": False,
110
+ }
111
+
112
+
113
+ @operation(
114
+ "segment",
115
+ "read",
116
+ "Chapters and scenes with stable ids and detection signals, or sentences and dialogue lines.",
117
+ {
118
+ "type": "object",
119
+ "properties": {
120
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
121
+ "unit": {
122
+ "type": "string",
123
+ "enum": _UNITS,
124
+ "description": "What to list (default chapters).",
125
+ },
126
+ "chapter": {
127
+ "type": "string",
128
+ "minLength": 1,
129
+ "description": "scenes only: the chapter id whose scenes to list.",
130
+ },
131
+ "range": {
132
+ "type": "string",
133
+ "minLength": 1,
134
+ "description": "sentences and dialogue only: paragraph address or range to cover.",
135
+ },
136
+ "language": {
137
+ "type": "string",
138
+ "enum": list(LANGUAGES),
139
+ "description": "Sentence rules (default es).",
140
+ },
141
+ "overrides": OVERRIDES_SCHEMA,
142
+ "profile": PROFILE_SCHEMA,
143
+ **PAGING_PROPERTIES,
144
+ },
145
+ "required": ["doc"],
146
+ "additionalProperties": False,
147
+ },
148
+ )
149
+ def segment(request: dict[str, Any]) -> Reply:
150
+ doc = Document(request["doc"])
151
+ unit = request.get("unit", "chapters")
152
+ language = request.get("language", DEFAULT_LANGUAGE)
153
+ if unit == "sentences":
154
+ return paged("segment", doc, request, "sentences", _sentences(doc, request, language))
155
+ if unit == "dialogue":
156
+ return paged("segment", doc, request, "dialogue", _dialogue(doc, request))
157
+ found = detect(paragraphs_of(doc), profile_of(request), overrides_of(request))
158
+ base: dict[str, Any] = {"unit": unit}
159
+ if found.unmatched_overrides:
160
+ base["unmatched_overrides"] = [
161
+ {
162
+ "fp": o.fp,
163
+ "kind": o.kind,
164
+ "action": o.action,
165
+ **({"nth": o.nth} if o.nth is not None else {}),
166
+ }
167
+ for o in found.unmatched_overrides
168
+ ]
169
+ kind = "chapter" if unit == "chapters" else "scene"
170
+ shown = [s for s in found.suppressed if s[1] == kind]
171
+ if shown:
172
+ base["suppressed_total"] = len(shown)
173
+ base["suppressed"] = [
174
+ {"at": str(_address(doc, n)), "kind": k, "reason": reason}
175
+ for n, k, reason in shown[:_SUPPRESSED_LIMIT]
176
+ ]
177
+ if unit == "chapters":
178
+ return paged("segment", doc, request, "chapters", _chapters(doc, found), base)
179
+ return paged("segment", doc, request, "scenes", _scenes(doc, request, found), base)
180
+
181
+
182
+ def _address(doc: Document, number: int) -> Address:
183
+ return doc.index.paragraph(number).address
184
+
185
+
186
+ def profile_of(request: dict[str, Any]) -> Profile:
187
+ language = request.get("language", DEFAULT_LANGUAGE)
188
+ return Profile(**{"language": language, **request.get("profile", {})})
189
+
190
+
191
+ def overrides_of(request: dict[str, Any]) -> list[Override]:
192
+ out: list[Override] = []
193
+ for i, raw in enumerate(request.get("overrides", [])):
194
+ fp = raw["fp"]
195
+ if len(fp) != FINGERPRINT_LENGTH or not set(fp) <= _HEX:
196
+ raise DocControlError(
197
+ ErrorCode.SCHEMA_INVALID,
198
+ f"override fingerprint must be {FINGERPRINT_LENGTH} lower-case hex digits",
199
+ path=f"$.overrides[{i}].fp",
200
+ )
201
+ out.append(Override(fp, raw["kind"], raw["action"], raw.get("nth")))
202
+ return out
203
+
204
+
205
+ def _chapters(doc: Document, found: Structure) -> list[dict[str, Any]]:
206
+ scenes: dict[str | None, int] = {}
207
+ for scene in found.scenes:
208
+ scenes[scene.chapter] = scenes.get(scene.chapter, 0) + 1
209
+ out: list[dict[str, Any]] = []
210
+ for chapter in found.chapters:
211
+ para = doc.index.paragraph(chapter.first)
212
+ out.append(
213
+ {
214
+ "id": chapter.id,
215
+ "at": str(para.address),
216
+ "kind": chapter.kind,
217
+ "number": chapter.number,
218
+ "text": " ".join(para.text.split())[:_TEXT],
219
+ "first": chapter.first,
220
+ "last": chapter.last,
221
+ "words": chapter.words,
222
+ "scenes": scenes.get(chapter.id, 0),
223
+ "signals": list(chapter.signals),
224
+ }
225
+ )
226
+ return out
227
+
228
+
229
+ def _scenes(doc: Document, request: dict[str, Any], found: Structure) -> list[dict[str, Any]]:
230
+ wanted = request.get("chapter")
231
+ if wanted is not None and wanted not in {c.id for c in found.chapters}:
232
+ raise DocControlError(
233
+ ErrorCode.ADDRESS_INVALID, f"no chapter {wanted!r} in the document", chapter=wanted
234
+ )
235
+ return [
236
+ {
237
+ "id": scene.id,
238
+ "chapter": scene.chapter,
239
+ "at": str(_address(doc, scene.first)),
240
+ "first": scene.first,
241
+ "last": scene.last,
242
+ "words": scene.words,
243
+ "signals": list(scene.signals),
244
+ }
245
+ for scene in found.scenes
246
+ if wanted is None or scene.chapter == wanted
247
+ ]
248
+
249
+
250
+ def _paragraphs_in(doc: Document, request: dict[str, Any]) -> range:
251
+ if "range" not in request:
252
+ return range(1, len(doc.index) + 1)
253
+ resolved = doc.index.resolve_range(request["range"], request.get("language", DEFAULT_LANGUAGE))
254
+ return range(resolved.first.number, resolved.last.number + 1)
255
+
256
+
257
+ def _sentences(doc: Document, request: dict[str, Any], language: str) -> list[dict[str, Any]]:
258
+ out: list[dict[str, Any]] = []
259
+ for number in _paragraphs_in(doc, request):
260
+ para = doc.index.paragraph(number)
261
+ for k, span in enumerate(split_sentences(para.text, language), 1):
262
+ out.append(
263
+ {
264
+ "at": f"{para.address}.s{k}",
265
+ "start": span.start,
266
+ "end": span.end,
267
+ "text": span.text(para.text)[:_TEXT],
268
+ }
269
+ )
270
+ return out
271
+
272
+
273
+ def _dialogue(doc: Document, request: dict[str, Any]) -> list[dict[str, Any]]:
274
+ out: list[dict[str, Any]] = []
275
+ for number in _paragraphs_in(doc, request):
276
+ para = doc.index.paragraph(number)
277
+ for line in dialogue_lines(para.text):
278
+ out.append(
279
+ {
280
+ "at": str(para.address),
281
+ "start": line.start,
282
+ "end": line.end,
283
+ "parts": [
284
+ {"kind": part.kind, "start": part.start, "end": part.end}
285
+ for part in line.parts
286
+ ],
287
+ }
288
+ )
289
+ return out
@@ -0,0 +1,262 @@
1
+ """JSON style sets: ``styleset.export`` (read) and ``styleset.install`` (write).
2
+
3
+ A style set is ``{"styleset": name, "version": 1, "styles": [...]}``; each style carries
4
+ ``id``, ``name``, ``type``, ``based_on``, ``next``, ``link``, ``default``, ``custom``,
5
+ ``quick`` and the ``run`` / ``paragraph`` formatting in the vocabulary of ``style.get``
6
+ (lengths in twips). Installing an exported set into a document leaves the definitions
7
+ of those styles equal to the exported ones. What a set does not carry (table style
8
+ conditional formatting, numbering links, theme fonts, latent styles) is left as it is
9
+ in the document on a replace and absent on a create.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from doc_control.envelope import Reply
19
+ from doc_control.errors import DocControlError, ErrorCode
20
+ from doc_control.model.document import Document
21
+ from doc_control.model.styles import Style
22
+ from doc_control.ooxml.styledef import apply_properties, invalid, set_child
23
+ from doc_control.ops.stylewrite import (
24
+ TYPES,
25
+ StyleSession,
26
+ apply_refs,
27
+ commit_styles,
28
+ create_style,
29
+ )
30
+ from doc_control.registry import operation
31
+
32
+ VERSION = 1
33
+ _DOC = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
34
+
35
+
36
+ def style_entry(style: Style) -> dict[str, Any]:
37
+ entry: dict[str, Any] = {"id": style.id, "name": style.name, "type": style.type}
38
+ for key in ("based_on", "next", "link"):
39
+ if getattr(style, key):
40
+ entry[key] = getattr(style, key)
41
+ for key in ("default", "custom", "quick"):
42
+ if getattr(style, key):
43
+ entry[key] = True
44
+ if style.rpr:
45
+ entry["run"] = style.rpr
46
+ if style.ppr:
47
+ entry["paragraph"] = style.ppr
48
+ return entry
49
+
50
+
51
+ @operation(
52
+ "styleset.export",
53
+ "read",
54
+ "The document's styles as a JSON style set; optionally also saved to a file.",
55
+ {
56
+ "type": "object",
57
+ "properties": {
58
+ "doc": _DOC,
59
+ "name": {"type": "string", "minLength": 1, "description": "Set name."},
60
+ "styles": {
61
+ "type": "array",
62
+ "items": {"type": "string", "minLength": 1},
63
+ "description": "Only these styles (ids or names).",
64
+ },
65
+ "custom_only": {"type": "boolean", "description": "Only user-defined styles."},
66
+ "out": {"type": "string", "minLength": 1, "description": "Also write the JSON here."},
67
+ },
68
+ "required": ["doc"],
69
+ "additionalProperties": False,
70
+ },
71
+ )
72
+ def styleset_export(request: dict[str, Any]) -> Reply:
73
+ doc = Document(request["doc"])
74
+ styleset = select_styleset(doc, request)
75
+ entries = styleset["styles"]
76
+ result: dict[str, Any] = {"styleset": styleset, "count": len(entries)}
77
+ if "out" in request:
78
+ write_new_file(Path(request["out"]), Path(request["doc"]), _json_bytes(styleset))
79
+ result["path"] = request["out"]
80
+ return Reply(result, doc=doc.envelope_doc())
81
+
82
+
83
+ def _json_bytes(styleset: dict[str, Any]) -> bytes:
84
+ return (json.dumps(styleset, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
85
+
86
+
87
+ def select_styleset(doc: Document, request: dict[str, Any]) -> dict[str, Any]:
88
+ """The document's styles (``styles`` / ``custom_only`` applied) as a style set."""
89
+ chosen = request.get("styles")
90
+ wanted: set[str] | None = None
91
+ if chosen is not None:
92
+ wanted = set()
93
+ for key in chosen:
94
+ found = doc.styles.find(key)
95
+ if found is None:
96
+ raise DocControlError(ErrorCode.STYLE_NOT_FOUND, f"no style {key!r}", style=key)
97
+ wanted.add(found.id)
98
+ entries = [
99
+ style_entry(s)
100
+ for s in doc.styles.styles
101
+ if (wanted is None or s.id in wanted) and not (request.get("custom_only") and not s.custom)
102
+ ]
103
+ return {
104
+ "styleset": request.get("name") or Path(request["doc"]).stem,
105
+ "version": VERSION,
106
+ "styles": entries,
107
+ }
108
+
109
+
110
+ def write_new_file(out: Path, doc: Path, payload: bytes) -> None:
111
+ """Write the set to a file that does not exist yet; never the document itself."""
112
+ same = out.exists() and doc.exists() and out.samefile(doc)
113
+ if same or out.resolve() == doc.resolve():
114
+ raise DocControlError(
115
+ ErrorCode.SCHEMA_INVALID, f"out is the document: {out}", path=str(out)
116
+ )
117
+ if out.exists():
118
+ raise DocControlError(
119
+ ErrorCode.SCHEMA_INVALID,
120
+ f"out already exists, nothing was written: {out}",
121
+ path=str(out),
122
+ )
123
+ try:
124
+ with out.open("xb") as fh:
125
+ fh.write(payload)
126
+ except FileExistsError as exc:
127
+ raise DocControlError(
128
+ ErrorCode.SCHEMA_INVALID, f"out already exists: {out}", path=str(out)
129
+ ) from exc
130
+ except OSError as exc:
131
+ raise DocControlError(
132
+ ErrorCode.SCHEMA_INVALID,
133
+ f"cannot write out: {type(exc).__name__}",
134
+ path=str(out),
135
+ ) from exc
136
+
137
+
138
+ def _validated(styleset: Any) -> list[dict[str, Any]]:
139
+ if not isinstance(styleset, dict) or not isinstance(styleset.get("styles"), list):
140
+ raise invalid("a style set is an object with a 'styles' list")
141
+ if styleset.get("version") != VERSION:
142
+ raise invalid(
143
+ f"unsupported style set version {styleset.get('version')!r}", supported=VERSION
144
+ )
145
+ seen: set[str] = set()
146
+ for i, entry in enumerate(styleset["styles"]):
147
+ if not isinstance(entry, dict):
148
+ raise invalid(f"styles[{i}] must be an object")
149
+ for key in ("id", "name"):
150
+ if not isinstance(entry.get(key), str) or not entry[key]:
151
+ raise invalid(f"styles[{i}].{key} must be a non-empty string")
152
+ if entry.get("type", "paragraph") not in TYPES:
153
+ raise invalid(f"styles[{i}].type must be one of {', '.join(TYPES)}")
154
+ if entry["id"] in seen:
155
+ raise invalid(f"styles[{i}]: id {entry['id']!r} appears twice")
156
+ seen.add(entry["id"])
157
+ entries: list[dict[str, Any]] = styleset["styles"]
158
+ return entries
159
+
160
+
161
+ def _install(
162
+ session: StyleSession, entries: list[dict[str, Any]], mode: str
163
+ ) -> list[dict[str, Any]]:
164
+ out: list[dict[str, Any]] = []
165
+ for entry in entries:
166
+ sid = entry["id"]
167
+ existing = session.records().get(sid)
168
+ if existing is None:
169
+ spec = dict(entry)
170
+ spec["quick"] = bool(entry.get("quick", False))
171
+ spec["custom"] = bool(entry.get("custom", False))
172
+ kind = spec.get("type", "paragraph")
173
+ if spec.get("default") and any(
174
+ r.default and r.type == kind for r in session.records().values()
175
+ ):
176
+ spec["default"] = False # the document keeps its own default
177
+ create_style(session, spec)
178
+ out.append({"style": sid, "action": "created"})
179
+ continue
180
+ if mode == "skip":
181
+ out.append({"style": sid, "action": "skipped"})
182
+ continue
183
+ if mode == "error":
184
+ raise DocControlError(
185
+ ErrorCode.STYLE_CONFLICT, f"style {sid!r} already exists", style=sid
186
+ )
187
+ if existing.type != entry.get("type", "paragraph"):
188
+ raise DocControlError(
189
+ ErrorCode.STYLE_CONFLICT,
190
+ f"style {sid!r} is a {existing.type} style in the document, "
191
+ f"the set has a {entry.get('type', 'paragraph')} style",
192
+ style=sid,
193
+ )
194
+ others = [r for r in session.records().values() if r.id != sid]
195
+ if any(r.name.lower() == entry["name"].lower() for r in others):
196
+ raise DocControlError(
197
+ ErrorCode.STYLE_CONFLICT, f"a style named {entry['name']!r} already exists"
198
+ )
199
+ el = session.styles.element(sid)
200
+ set_child(el, "name", entry["name"])
201
+ apply_refs(
202
+ el,
203
+ {
204
+ "based_on": entry.get("based_on", ""),
205
+ "next": entry.get("next", ""),
206
+ "link": entry.get("link", ""),
207
+ "quick": bool(entry.get("quick", False)),
208
+ },
209
+ session,
210
+ )
211
+ apply_properties(el, entry.get("run", {}), entry.get("paragraph", {}), replace=True)
212
+ session.styles.put(el)
213
+ session.touched.add(sid)
214
+ out.append({"style": sid, "action": "replaced"})
215
+ return out
216
+
217
+
218
+ @operation(
219
+ "styleset.install",
220
+ "write",
221
+ "Install a JSON style set into the document: new styles are added, existing ones "
222
+ "replaced (or skipped); dry_run previews.",
223
+ {
224
+ "type": "object",
225
+ "properties": {
226
+ "doc": _DOC,
227
+ "styleset": {"type": "object", "description": "The style set (inline)."},
228
+ "path": {"type": "string", "minLength": 1, "description": "Or a style set file."},
229
+ "on_conflict": {
230
+ "enum": ["replace", "skip", "error"],
231
+ "description": "A style id already in the document (default replace).",
232
+ },
233
+ "expect": {
234
+ "type": "object",
235
+ "properties": {"sha256": {"type": "string", "minLength": 1}},
236
+ "additionalProperties": False,
237
+ },
238
+ "dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
239
+ },
240
+ "required": ["doc"],
241
+ "additionalProperties": False,
242
+ },
243
+ )
244
+ def styleset_install(request: dict[str, Any]) -> Reply:
245
+ if ("styleset" in request) == ("path" in request):
246
+ raise invalid("give exactly one of 'styleset' and 'path'")
247
+ if "path" in request:
248
+ try:
249
+ data = json.loads(Path(request["path"]).read_text(encoding="utf-8"))
250
+ except (OSError, ValueError) as exc:
251
+ raise invalid(f"cannot read the style set: {type(exc).__name__}") from exc
252
+ else:
253
+ data = request["styleset"]
254
+ entries = _validated(data)
255
+ mode = request.get("on_conflict", "replace")
256
+ result = commit_styles(
257
+ Path(request["doc"]),
258
+ lambda session: _install(session, entries, mode),
259
+ expect_sha256=request.get("expect", {}).get("sha256"),
260
+ dry_run=request.get("dry_run", False),
261
+ )
262
+ return Reply(result, doc=Document(request["doc"]).envelope_doc())