doc-control 0.0.1__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 +93 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/mcp.py +94 -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 +161 -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/bundled/blank-paragraphs.json +16 -0
- doc_control/recipes/bundled/chapter-heading-period.json +17 -0
- doc_control/recipes/bundled/chapter-heading-style.json +17 -0
- doc_control/recipes/bundled/dashes-em.json +17 -0
- doc_control/recipes/bundled/dashes-spaced-en.json +19 -0
- doc_control/recipes/bundled/fake-centring.json +22 -0
- doc_control/recipes/bundled/fake-tabs.json +17 -0
- doc_control/recipes/bundled/leading-spaces.json +17 -0
- doc_control/recipes/bundled/leftover-markers.json +17 -0
- doc_control/recipes/bundled/quotes-de.json +17 -0
- doc_control/recipes/bundled/quotes-en-single.json +17 -0
- doc_control/recipes/bundled/quotes-en.json +17 -0
- doc_control/recipes/bundled/quotes-fr.json +17 -0
- doc_control/recipes/bundled/scene-break.json +22 -0
- doc_control/recipes/bundled/tab-indent.json +22 -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.dist-info/METADATA +76 -0
- doc_control-0.0.1.dist-info/RECORD +84 -0
- doc_control-0.0.1.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dist-info/entry_points.txt +2 -0
- doc_control-0.0.1.dist-info/licenses/LICENSE +202 -0
doc_control/__init__.py
ADDED
doc_control/__main__.py
ADDED
doc_control/cli.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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`. `doc-control mcp` serves the same operations as an
|
|
5
|
+
MCP server on stdio (see `doc_control.mcp`).
|
|
6
|
+
|
|
7
|
+
Never prompts and never reads stdin unless asked with `--json -`, so an agent whose
|
|
8
|
+
stdin is an open pipe cannot hang it. Prints exactly one JSON envelope on stdout. Exit codes:
|
|
9
|
+
0 success, 1 operation error, 2 usage error (unknown op or invalid request).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from doc_control.envelope import Reply, failure, success
|
|
19
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
20
|
+
from doc_control.registry import Operation, load_operations
|
|
21
|
+
|
|
22
|
+
_USAGE_CODES = {ErrorCode.SCHEMA_INVALID}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
args = sys.argv[1:] if argv is None else argv
|
|
27
|
+
registry = load_operations()
|
|
28
|
+
op = args[0] if args else "help"
|
|
29
|
+
if op == "mcp":
|
|
30
|
+
from doc_control.mcp import serve
|
|
31
|
+
|
|
32
|
+
return serve()
|
|
33
|
+
if op in ("--version", "version"):
|
|
34
|
+
op = "capabilities"
|
|
35
|
+
elif op in ("--help", "-h"):
|
|
36
|
+
op = "help"
|
|
37
|
+
rest = args[1:]
|
|
38
|
+
try:
|
|
39
|
+
if op == "help" and len(rest) == 1 and not rest[0].startswith("-"):
|
|
40
|
+
request: dict[str, Any] = {"op": rest[0]}
|
|
41
|
+
else:
|
|
42
|
+
request = _read_request(rest)
|
|
43
|
+
except DocControlError as err:
|
|
44
|
+
envelope, code = failure(op, err), 2
|
|
45
|
+
else:
|
|
46
|
+
envelope, code = execute(registry, op, request)
|
|
47
|
+
print(json.dumps(envelope, ensure_ascii=False))
|
|
48
|
+
return code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def execute(
|
|
52
|
+
registry: dict[str, Operation], op: str, request: dict[str, Any]
|
|
53
|
+
) -> tuple[dict[str, Any], int]:
|
|
54
|
+
"""Run one operation; return its envelope and the CLI exit code. Never raises."""
|
|
55
|
+
try:
|
|
56
|
+
if op not in registry:
|
|
57
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unknown operation: {op}", op=op)
|
|
58
|
+
out = registry[op].run(request)
|
|
59
|
+
if isinstance(out, Reply):
|
|
60
|
+
return success(
|
|
61
|
+
op, out.result, doc=out.doc, warnings=out.warnings, next_cursor=out.next_cursor
|
|
62
|
+
), 0
|
|
63
|
+
return success(op, out), 0
|
|
64
|
+
except DocControlError as err:
|
|
65
|
+
return failure(op, err), 2 if err.code in _USAGE_CODES else 1
|
|
66
|
+
except Exception as err: # never leak a traceback instead of an envelope
|
|
67
|
+
internal = DocControlError(ErrorCode.INTERNAL, f"{type(err).__name__}: {err}")
|
|
68
|
+
return failure(op, internal), 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_request(rest: list[str]) -> dict[str, Any]:
|
|
72
|
+
raw: str | None = None
|
|
73
|
+
if rest[:1] == ["--json"]:
|
|
74
|
+
if len(rest) < 2:
|
|
75
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "--json needs a value")
|
|
76
|
+
raw = sys.stdin.read() if rest[1] == "-" else rest[1]
|
|
77
|
+
if len(rest) > 2:
|
|
78
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unexpected arguments: {rest[2:]}")
|
|
79
|
+
elif rest:
|
|
80
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unexpected arguments: {rest}")
|
|
81
|
+
if not raw or not raw.strip():
|
|
82
|
+
return {}
|
|
83
|
+
try:
|
|
84
|
+
value = json.loads(raw)
|
|
85
|
+
except json.JSONDecodeError as err:
|
|
86
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"request is not JSON: {err}") from err
|
|
87
|
+
if not isinstance(value, dict):
|
|
88
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "request must be a JSON object")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
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
|
doc_control/mcp.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""MCP front door: `doc-control mcp` serves every registered operation as a tool, on stdio.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the operation registry (decision D-8): one tool per operation, the
|
|
4
|
+
operation's JSON Schema as the tool's input schema, and the usual response envelope as
|
|
5
|
+
the tool result. The transport is newline-delimited JSON-RPC 2.0 on stdin/stdout, which
|
|
6
|
+
is all a tools-only MCP server needs, so this stays standard library only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
from doc_control import __version__
|
|
16
|
+
from doc_control.cli import execute
|
|
17
|
+
from doc_control.registry import Operation, load_operations
|
|
18
|
+
|
|
19
|
+
# Protocol revisions this server speaks; the newest is offered to a client that asks for another.
|
|
20
|
+
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool_name(op: str) -> str:
|
|
24
|
+
"""Operation names have dots (`para.move`); many MCP clients only accept [A-Za-z0-9_-]."""
|
|
25
|
+
return op.replace(".", "_")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tools(registry: dict[str, Operation]) -> list[dict[str, Any]]:
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
"name": tool_name(op.name),
|
|
32
|
+
"description": op.summary,
|
|
33
|
+
"inputSchema": op.schema,
|
|
34
|
+
"annotations": {"readOnlyHint": op.kind == "read"},
|
|
35
|
+
}
|
|
36
|
+
for op in sorted(registry.values(), key=lambda o: o.name)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle(registry: dict[str, Operation], message: Any) -> dict[str, Any] | None:
|
|
41
|
+
"""Answer one JSON-RPC message; None for a notification."""
|
|
42
|
+
if not isinstance(message, dict) or not isinstance(message.get("method"), str):
|
|
43
|
+
return _error(None, -32600, "invalid request")
|
|
44
|
+
if "id" not in message:
|
|
45
|
+
return None
|
|
46
|
+
msg_id, method = message["id"], message["method"]
|
|
47
|
+
params = message.get("params")
|
|
48
|
+
params = params if isinstance(params, dict) else {}
|
|
49
|
+
if method == "initialize":
|
|
50
|
+
asked = params.get("protocolVersion")
|
|
51
|
+
result: dict[str, Any] = {
|
|
52
|
+
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
|
|
53
|
+
"capabilities": {"tools": {}},
|
|
54
|
+
"serverInfo": {"name": "doc-control", "version": __version__},
|
|
55
|
+
}
|
|
56
|
+
elif method == "ping":
|
|
57
|
+
result = {}
|
|
58
|
+
elif method == "tools/list":
|
|
59
|
+
result = {"tools": _tools(registry)}
|
|
60
|
+
elif method == "tools/call":
|
|
61
|
+
ops = {tool_name(name): name for name in registry}
|
|
62
|
+
name, arguments = params.get("name"), params.get("arguments", {})
|
|
63
|
+
if name not in ops or not isinstance(arguments, dict):
|
|
64
|
+
return _error(msg_id, -32602, f"unknown tool or invalid arguments: {name}")
|
|
65
|
+
envelope, _ = execute(registry, ops[name], arguments)
|
|
66
|
+
result = {
|
|
67
|
+
"content": [{"type": "text", "text": json.dumps(envelope, ensure_ascii=False)}],
|
|
68
|
+
"isError": not envelope["ok"],
|
|
69
|
+
}
|
|
70
|
+
else:
|
|
71
|
+
return _error(msg_id, -32601, f"method not found: {method}")
|
|
72
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
76
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def serve(stdin: IO[bytes] | None = None, stdout: IO[bytes] | None = None) -> int:
|
|
80
|
+
"""Serve until stdin closes. Bytes in and out, so the encoding is UTF-8 on every platform."""
|
|
81
|
+
stdin = sys.stdin.buffer if stdin is None else stdin
|
|
82
|
+
stdout = sys.stdout.buffer if stdout is None else stdout
|
|
83
|
+
registry = load_operations()
|
|
84
|
+
for line in stdin:
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
reply = handle(registry, json.loads(line))
|
|
89
|
+
except ValueError:
|
|
90
|
+
reply = _error(None, -32700, "parse error")
|
|
91
|
+
if reply is not None:
|
|
92
|
+
stdout.write(json.dumps(reply, ensure_ascii=False).encode() + b"\n")
|
|
93
|
+
stdout.flush()
|
|
94
|
+
return 0
|
|
@@ -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)
|