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.
Files changed (84) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +93 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/mcp.py +94 -0
  7. doc_control/model/__init__.py +1 -0
  8. doc_control/model/address.py +113 -0
  9. doc_control/model/align.py +529 -0
  10. doc_control/model/anchor.py +609 -0
  11. doc_control/model/annotations.py +280 -0
  12. doc_control/model/document.py +109 -0
  13. doc_control/model/format.py +109 -0
  14. doc_control/model/index.py +353 -0
  15. doc_control/model/layout.py +143 -0
  16. doc_control/model/normalized.py +160 -0
  17. doc_control/model/pages.py +403 -0
  18. doc_control/model/parts.py +50 -0
  19. doc_control/model/search.py +403 -0
  20. doc_control/model/segment.py +445 -0
  21. doc_control/model/structure.py +418 -0
  22. doc_control/model/styles.py +134 -0
  23. doc_control/ooxml/__init__.py +1 -0
  24. doc_control/ooxml/commentparts.py +679 -0
  25. doc_control/ooxml/formatting.py +181 -0
  26. doc_control/ooxml/resolve.py +269 -0
  27. doc_control/ooxml/revisions.py +1024 -0
  28. doc_control/ooxml/splice.py +957 -0
  29. doc_control/ooxml/styledef.py +504 -0
  30. doc_control/ooxml/zipio.py +454 -0
  31. doc_control/ops/__init__.py +1 -0
  32. doc_control/ops/anchor.py +135 -0
  33. doc_control/ops/batch.py +177 -0
  34. doc_control/ops/comments.py +433 -0
  35. doc_control/ops/compare.py +620 -0
  36. doc_control/ops/diff.py +177 -0
  37. doc_control/ops/dotx.py +206 -0
  38. doc_control/ops/find.py +190 -0
  39. doc_control/ops/history.py +479 -0
  40. doc_control/ops/layout.py +40 -0
  41. doc_control/ops/locate.py +364 -0
  42. doc_control/ops/meta.py +49 -0
  43. doc_control/ops/page.py +104 -0
  44. doc_control/ops/paging.py +124 -0
  45. doc_control/ops/read.py +517 -0
  46. doc_control/ops/recipe.py +161 -0
  47. doc_control/ops/rename.py +370 -0
  48. doc_control/ops/revision.py +616 -0
  49. doc_control/ops/segment.py +289 -0
  50. doc_control/ops/styleset.py +262 -0
  51. doc_control/ops/stylewrite.py +573 -0
  52. doc_control/ops/text.py +207 -0
  53. doc_control/ops/tracked.py +1441 -0
  54. doc_control/py.typed +0 -0
  55. doc_control/recipes/bundled/blank-paragraphs.json +16 -0
  56. doc_control/recipes/bundled/chapter-heading-period.json +17 -0
  57. doc_control/recipes/bundled/chapter-heading-style.json +17 -0
  58. doc_control/recipes/bundled/dashes-em.json +17 -0
  59. doc_control/recipes/bundled/dashes-spaced-en.json +19 -0
  60. doc_control/recipes/bundled/fake-centring.json +22 -0
  61. doc_control/recipes/bundled/fake-tabs.json +17 -0
  62. doc_control/recipes/bundled/leading-spaces.json +17 -0
  63. doc_control/recipes/bundled/leftover-markers.json +17 -0
  64. doc_control/recipes/bundled/quotes-de.json +17 -0
  65. doc_control/recipes/bundled/quotes-en-single.json +17 -0
  66. doc_control/recipes/bundled/quotes-en.json +17 -0
  67. doc_control/recipes/bundled/quotes-fr.json +17 -0
  68. doc_control/recipes/bundled/scene-break.json +22 -0
  69. doc_control/recipes/bundled/tab-indent.json +22 -0
  70. doc_control/recipes/engine.py +209 -0
  71. doc_control/recipes/schema.py +140 -0
  72. doc_control/recipes/store.py +96 -0
  73. doc_control/registry.py +76 -0
  74. doc_control/safety/__init__.py +38 -0
  75. doc_control/safety/history.py +508 -0
  76. doc_control/safety/lock.py +69 -0
  77. doc_control/safety/store.py +415 -0
  78. doc_control/safety/verify.py +357 -0
  79. doc_control/schema.py +143 -0
  80. doc_control-0.0.1.dist-info/METADATA +76 -0
  81. doc_control-0.0.1.dist-info/RECORD +84 -0
  82. doc_control-0.0.1.dist-info/WHEEL +4 -0
  83. doc_control-0.0.1.dist-info/entry_points.txt +2 -0
  84. doc_control-0.0.1.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,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())
@@ -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)