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/__init__.py
ADDED
doc_control/__main__.py
ADDED
doc_control/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command-line front door: `doc-control <op> [--json '<request>' | --json - < request.json]`.
|
|
2
|
+
|
|
3
|
+
`doc-control help <op>` is shorthand for `doc-control help --json '{"op": "<op>"}'`, and
|
|
4
|
+
`--help` / `-h` are aliases of `help`.
|
|
5
|
+
|
|
6
|
+
Never prompts and never reads stdin unless asked with `--json -`, so an agent whose
|
|
7
|
+
stdin is an open pipe cannot hang it. Prints exactly one JSON envelope on stdout. Exit codes:
|
|
8
|
+
0 success, 1 operation error, 2 usage error (unknown op or invalid request).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from doc_control.envelope import Reply, failure, success
|
|
18
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
19
|
+
from doc_control.registry import load_operations
|
|
20
|
+
|
|
21
|
+
_USAGE_CODES = {ErrorCode.SCHEMA_INVALID}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
args = sys.argv[1:] if argv is None else argv
|
|
26
|
+
registry = load_operations()
|
|
27
|
+
op = args[0] if args else "help"
|
|
28
|
+
if op in ("--version", "version"):
|
|
29
|
+
op = "capabilities"
|
|
30
|
+
elif op in ("--help", "-h"):
|
|
31
|
+
op = "help"
|
|
32
|
+
try:
|
|
33
|
+
rest = args[1:]
|
|
34
|
+
if op == "help" and len(rest) == 1 and not rest[0].startswith("-"):
|
|
35
|
+
request: dict[str, Any] = {"op": rest[0]}
|
|
36
|
+
else:
|
|
37
|
+
request = _read_request(rest)
|
|
38
|
+
if op not in registry:
|
|
39
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unknown operation: {op}", op=op)
|
|
40
|
+
out = registry[op].run(request)
|
|
41
|
+
if isinstance(out, Reply):
|
|
42
|
+
envelope = success(
|
|
43
|
+
op, out.result, doc=out.doc, warnings=out.warnings, next_cursor=out.next_cursor
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
envelope = success(op, out)
|
|
47
|
+
code = 0
|
|
48
|
+
except DocControlError as err:
|
|
49
|
+
envelope = failure(op, err)
|
|
50
|
+
code = 2 if err.code in _USAGE_CODES else 1
|
|
51
|
+
except Exception as err: # never leak a traceback instead of an envelope
|
|
52
|
+
envelope = failure(op, DocControlError(ErrorCode.INTERNAL, f"{type(err).__name__}: {err}"))
|
|
53
|
+
code = 1
|
|
54
|
+
print(json.dumps(envelope, ensure_ascii=False))
|
|
55
|
+
return code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_request(rest: list[str]) -> dict[str, Any]:
|
|
59
|
+
raw: str | None = None
|
|
60
|
+
if rest[:1] == ["--json"]:
|
|
61
|
+
if len(rest) < 2:
|
|
62
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "--json needs a value")
|
|
63
|
+
raw = sys.stdin.read() if rest[1] == "-" else rest[1]
|
|
64
|
+
if len(rest) > 2:
|
|
65
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unexpected arguments: {rest[2:]}")
|
|
66
|
+
elif rest:
|
|
67
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unexpected arguments: {rest}")
|
|
68
|
+
if not raw or not raw.strip():
|
|
69
|
+
return {}
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(raw)
|
|
72
|
+
except json.JSONDecodeError as err:
|
|
73
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"request is not JSON: {err}") from err
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "request must be a JSON object")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
raise SystemExit(main())
|
doc_control/envelope.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The response envelope every operation returns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from doc_control import API_VERSION
|
|
9
|
+
from doc_control.errors import DocControlError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Reply:
|
|
14
|
+
"""What a handler returns when it fills more of the envelope than ``result``."""
|
|
15
|
+
|
|
16
|
+
result: dict[str, Any]
|
|
17
|
+
doc: dict[str, Any] | None = None
|
|
18
|
+
warnings: list[str] = field(default_factory=list)
|
|
19
|
+
next_cursor: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def success(
|
|
23
|
+
op: str,
|
|
24
|
+
result: dict[str, Any],
|
|
25
|
+
*,
|
|
26
|
+
doc: dict[str, Any] | None = None,
|
|
27
|
+
warnings: list[str] | None = None,
|
|
28
|
+
next_cursor: str | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
out: dict[str, Any] = {"api": API_VERSION, "ok": True, "op": op}
|
|
31
|
+
if doc is not None:
|
|
32
|
+
out["doc"] = doc
|
|
33
|
+
out["result"] = result
|
|
34
|
+
out["warnings"] = warnings or []
|
|
35
|
+
out["next"] = next_cursor
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def failure(op: str, error: DocControlError) -> dict[str, Any]:
|
|
40
|
+
return {"api": API_VERSION, "ok": False, "op": op, "error": error.to_dict()}
|
doc_control/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Stable error codes, each with a one-line hint on what to do next."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ErrorCode(StrEnum):
|
|
10
|
+
DOC_NOT_FOUND = "DOC_NOT_FOUND"
|
|
11
|
+
NOT_A_DOCX = "NOT_A_DOCX"
|
|
12
|
+
DOC_LOCKED = "DOC_LOCKED"
|
|
13
|
+
DOC_CHANGED_ON_DISK = "DOC_CHANGED_ON_DISK"
|
|
14
|
+
SCHEMA_INVALID = "SCHEMA_INVALID"
|
|
15
|
+
ADDRESS_INVALID = "ADDRESS_INVALID"
|
|
16
|
+
STALE_ADDRESS = "STALE_ADDRESS"
|
|
17
|
+
ANCHOR_NOT_FOUND = "ANCHOR_NOT_FOUND"
|
|
18
|
+
ANCHOR_AMBIGUOUS = "ANCHOR_AMBIGUOUS"
|
|
19
|
+
UNSUPPORTED_CONTENT = "UNSUPPORTED_CONTENT"
|
|
20
|
+
BATCH_CONFLICT = "BATCH_CONFLICT"
|
|
21
|
+
VERIFY_FAILED_RESTORED = "VERIFY_FAILED_RESTORED"
|
|
22
|
+
COMMENT_NOT_FOUND = "COMMENT_NOT_FOUND"
|
|
23
|
+
STYLE_NOT_FOUND = "STYLE_NOT_FOUND"
|
|
24
|
+
STYLE_CONFLICT = "STYLE_CONFLICT"
|
|
25
|
+
RECIPE_INVALID = "RECIPE_INVALID"
|
|
26
|
+
HISTORY_NOT_FOUND = "HISTORY_NOT_FOUND"
|
|
27
|
+
INTERNAL = "INTERNAL"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
HINTS: dict[ErrorCode, str] = {
|
|
31
|
+
ErrorCode.DOC_NOT_FOUND: "Check the path; it is resolved relative to the current directory.",
|
|
32
|
+
ErrorCode.NOT_A_DOCX: "Pass a Word .docx file; .doc and other formats are not supported.",
|
|
33
|
+
ErrorCode.DOC_LOCKED: "Word has the file open: save and close it in Word, then retry.",
|
|
34
|
+
ErrorCode.DOC_CHANGED_ON_DISK: "The file changed since you read it: re-read, then retry.",
|
|
35
|
+
ErrorCode.SCHEMA_INVALID: "Run `doc-control help <op>` to see the request schema.",
|
|
36
|
+
ErrorCode.ADDRESS_INVALID: "Use an address from a read result, e.g. p142#a7b2c3d4.",
|
|
37
|
+
ErrorCode.STALE_ADDRESS: "Re-read the paragraph (para.get) or resolve its anchor, then retry.",
|
|
38
|
+
ErrorCode.ANCHOR_NOT_FOUND: "Widen the quote or its context, or search with find.",
|
|
39
|
+
ErrorCode.ANCHOR_AMBIGUOUS: "Pick one of the candidates, or add prefix/suffix context.",
|
|
40
|
+
ErrorCode.UNSUPPORTED_CONTENT: (
|
|
41
|
+
"Edit around fields, hyperlinks or controls; encrypted or ZIP64 packages are not"
|
|
42
|
+
" supported: save a normal copy in Word."
|
|
43
|
+
),
|
|
44
|
+
ErrorCode.BATCH_CONFLICT: "Split overlapping operations into separate batches.",
|
|
45
|
+
ErrorCode.VERIFY_FAILED_RESTORED: "Nothing was changed; report the request that caused it.",
|
|
46
|
+
ErrorCode.COMMENT_NOT_FOUND: "List comments with comments.list and use an existing id.",
|
|
47
|
+
ErrorCode.STYLE_NOT_FOUND: "List styles with styles.list and use an existing id.",
|
|
48
|
+
ErrorCode.STYLE_CONFLICT: "Rename the style or merge it with style.merge.",
|
|
49
|
+
ErrorCode.RECIPE_INVALID: "Fix the recipe so it only uses the documented keys.",
|
|
50
|
+
ErrorCode.HISTORY_NOT_FOUND: "List history with history.list and use an existing op id.",
|
|
51
|
+
ErrorCode.INTERNAL: "Unexpected error; nothing was written. Report it with the request.",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DocControlError(Exception):
|
|
56
|
+
"""An error with a stable code; the CLI turns it into an error envelope."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, code: ErrorCode, message: str, **details: Any) -> None:
|
|
59
|
+
super().__init__(message)
|
|
60
|
+
self.code = code
|
|
61
|
+
self.message = message
|
|
62
|
+
self.details = details
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
out: dict[str, Any] = {
|
|
66
|
+
"code": self.code.value,
|
|
67
|
+
"message": self.message,
|
|
68
|
+
"hint": HINTS[self.code],
|
|
69
|
+
}
|
|
70
|
+
if self.details:
|
|
71
|
+
out["details"] = self.details
|
|
72
|
+
return out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Document model: the paragraph index, addresses and ranges."""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Paragraph addresses ``p<n>#<fp>`` and the ranges built on them.
|
|
2
|
+
|
|
3
|
+
``n`` is the 1-based index over the main story's paragraphs and ``fp`` the first
|
|
4
|
+
8 hex digits of BLAKE2s over the paragraph's visible text, NFC-normalised.
|
|
5
|
+
|
|
6
|
+
Forms accepted by :func:`parse_range` (offsets are characters of the visible
|
|
7
|
+
text, the end exclusive)::
|
|
8
|
+
|
|
9
|
+
p142#a7b2a7b2 the whole paragraph
|
|
10
|
+
p142#a7b2a7b2:10-15 inside one paragraph
|
|
11
|
+
p142#a7b2a7b2:10..p144#0c9e0c9e:3 across paragraphs (the end paragraph is later)
|
|
12
|
+
p142#a7b2a7b2.s3 the third sentence (resolved by segmentation)
|
|
13
|
+
|
|
14
|
+
Parsing is strict: numbers have no leading zeros and no sign. Upper-case hex is
|
|
15
|
+
accepted and lower-cased, so ``str(parse_range(s)) == s`` for every canonical
|
|
16
|
+
``s``. Anything else raises ``DocControlError(ADDRESS_INVALID)``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
import unicodedata
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
27
|
+
|
|
28
|
+
FINGERPRINT_LENGTH = 8
|
|
29
|
+
|
|
30
|
+
# Bounded digit runs keep int() clear of Python's digit-conversion limit.
|
|
31
|
+
_NUM = r"(?:0|[1-9][0-9]{0,11})"
|
|
32
|
+
_POS = r"[1-9][0-9]{0,11}"
|
|
33
|
+
_HEX = rf"[0-9a-fA-F]{{{FINGERPRINT_LENGTH}}}"
|
|
34
|
+
_RANGE = re.compile(
|
|
35
|
+
rf"p(?P<n1>{_POS})#(?P<f1>{_HEX})"
|
|
36
|
+
rf"(?::(?P<a>{_NUM})-(?P<b>{_NUM})"
|
|
37
|
+
rf"|:(?P<c>{_NUM})\.\.p(?P<n2>{_POS})#(?P<f2>{_HEX}):(?P<d>{_NUM})"
|
|
38
|
+
rf"|\.s(?P<s>{_POS}))?"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fingerprint(text: str) -> str:
|
|
43
|
+
"""The 8-hex-digit fingerprint of a paragraph's visible ``text``."""
|
|
44
|
+
data = unicodedata.normalize("NFC", text).encode("utf-8")
|
|
45
|
+
return hashlib.blake2s(data).hexdigest()[:FINGERPRINT_LENGTH]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Address:
|
|
50
|
+
"""``p<number>#<fp>``: a paragraph position plus a fingerprint of its text."""
|
|
51
|
+
|
|
52
|
+
number: int
|
|
53
|
+
fp: str
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
return f"p{self.number}#{self.fp}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AddressRange:
|
|
61
|
+
"""A parsed range. ``start`` and ``end`` are equal unless it crosses paragraphs.
|
|
62
|
+
|
|
63
|
+
Offsets are ``None`` for a whole paragraph or a sentence; ``sentence`` is the
|
|
64
|
+
1-based sentence number, only set for the ``.s<k>`` form.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
start: Address
|
|
68
|
+
end: Address
|
|
69
|
+
start_offset: int | None = None
|
|
70
|
+
end_offset: int | None = None
|
|
71
|
+
sentence: int | None = None
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
if self.sentence is not None:
|
|
75
|
+
return f"{self.start}.s{self.sentence}"
|
|
76
|
+
if self.start_offset is None or self.end_offset is None:
|
|
77
|
+
return str(self.start)
|
|
78
|
+
if self.start == self.end:
|
|
79
|
+
return f"{self.start}:{self.start_offset}-{self.end_offset}"
|
|
80
|
+
return f"{self.start}:{self.start_offset}..{self.end}:{self.end_offset}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _invalid(text: object, why: str) -> DocControlError:
|
|
84
|
+
return DocControlError(ErrorCode.ADDRESS_INVALID, f"{text!r} {why}", address=str(text))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_address(text: str) -> Address:
|
|
88
|
+
"""Parse a bare paragraph address (no offsets, no sentence)."""
|
|
89
|
+
rng = parse_range(text)
|
|
90
|
+
if rng.sentence is not None or rng.start_offset is not None:
|
|
91
|
+
raise _invalid(text, "is not a bare paragraph address like p142#a7b2a7b2")
|
|
92
|
+
return rng.start
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_range(text: str) -> AddressRange:
|
|
96
|
+
"""Parse any address or range form listed in the module docstring."""
|
|
97
|
+
m = _RANGE.fullmatch(text) if isinstance(text, str) else None
|
|
98
|
+
if m is None:
|
|
99
|
+
raise _invalid(text, "is not an address or range like p142#a7b2a7b2:10-15")
|
|
100
|
+
start = Address(int(m["n1"]), m["f1"].lower())
|
|
101
|
+
if m["s"] is not None:
|
|
102
|
+
return AddressRange(start, start, sentence=int(m["s"]))
|
|
103
|
+
if m["a"] is not None:
|
|
104
|
+
a, b = int(m["a"]), int(m["b"])
|
|
105
|
+
if a > b:
|
|
106
|
+
raise _invalid(text, "ends before it starts")
|
|
107
|
+
return AddressRange(start, start, a, b)
|
|
108
|
+
if m["c"] is not None:
|
|
109
|
+
end = Address(int(m["n2"]), m["f2"].lower())
|
|
110
|
+
if end.number <= start.number:
|
|
111
|
+
raise _invalid(text, "must end in a later paragraph than it starts")
|
|
112
|
+
return AddressRange(start, end, int(m["c"]), int(m["d"]))
|
|
113
|
+
return AddressRange(start, start)
|