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,4 @@
1
+ """doc-control: deterministic, LLM-first control of Word documents."""
2
+
3
+ __version__ = "0.0.1"
4
+ API_VERSION = "1.0"
@@ -0,0 +1,3 @@
1
+ from doc_control.cli import main
2
+
3
+ raise SystemExit(main())
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())
@@ -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)