python-hwpx-automation 6.0.3__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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical, non-XPath semantic paths for the HWPX agent facade."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ from .model import AgentContractError, NODE_KINDS
11
+
12
+ MAX_PATH_CHARS = 2048
13
+ MAX_PATH_SEGMENTS = 32
14
+ PATH_ATTRIBUTES = frozenset({"id", "name"})
15
+
16
+ _SEGMENT_RE = re.compile(
17
+ r"^(?P<kind>[a-z][a-z-]*)\["
18
+ r"(?:(?P<index>0*[1-9][0-9]*)|"
19
+ r"@(?P<attribute>[a-z]+)=(?P<value>\"(?:[^\"\\]|\\.)*\"))\]$"
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PathSegment:
25
+ kind: str
26
+ index: int | None = None
27
+ attribute: str | None = None
28
+ value: str | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ if self.kind not in NODE_KINDS or self.kind in {"document", "unsupported"}:
32
+ raise AgentContractError("unknown_kind", f"unknown path kind: {self.kind}", target="path")
33
+ positional = self.index is not None
34
+ attributed = self.attribute is not None or self.value is not None
35
+ if positional == attributed:
36
+ raise AgentContractError(
37
+ "invalid_syntax", "path segment requires exactly one index or attribute", target="path"
38
+ )
39
+ if self.index is not None and (isinstance(self.index, bool) or self.index < 1):
40
+ raise AgentContractError("invalid_syntax", "path indexes are one-based", target="path")
41
+ if attributed and self.attribute not in PATH_ATTRIBUTES:
42
+ raise AgentContractError(
43
+ "invalid_syntax", f"unsupported path attribute: {self.attribute}", target="path"
44
+ )
45
+ if attributed and (not isinstance(self.value, str) or not self.value or len(self.value) > 256):
46
+ raise AgentContractError("resource_limit", "path attribute value is invalid", target="path")
47
+
48
+ def canonical(self) -> str:
49
+ if self.index is not None:
50
+ return f"{self.kind}[{self.index}]"
51
+ value = json.dumps(self.value, ensure_ascii=False, separators=(",", ":"))
52
+ return f"{self.kind}[@{self.attribute}={value}]"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class SemanticPath:
57
+ segments: tuple[PathSegment, ...] = ()
58
+
59
+ @property
60
+ def canonical(self) -> str:
61
+ if not self.segments:
62
+ return "/"
63
+ return "/" + "/".join(segment.canonical() for segment in self.segments)
64
+
65
+ @property
66
+ def parent(self) -> "SemanticPath | None":
67
+ if not self.segments:
68
+ return None
69
+ return SemanticPath(self.segments[:-1])
70
+
71
+ def child(self, segment: PathSegment) -> "SemanticPath":
72
+ if len(self.segments) >= MAX_PATH_SEGMENTS:
73
+ raise AgentContractError("resource_limit", "path nesting exceeds limit", target="path")
74
+ return SemanticPath((*self.segments, segment))
75
+
76
+
77
+ def parse_path(value: str) -> SemanticPath:
78
+ """Parse and canonicalize a bounded semantic path without invoking XPath."""
79
+
80
+ if not isinstance(value, str) or not value:
81
+ raise AgentContractError("invalid_syntax", "path must be a non-empty string", target="path")
82
+ if len(value) > MAX_PATH_CHARS:
83
+ raise AgentContractError("resource_limit", "path is too long", target="path")
84
+ if value == "/":
85
+ return SemanticPath()
86
+ if not value.startswith("/") or value.endswith("/"):
87
+ raise AgentContractError("invalid_syntax", "path must be absolute and canonical", target="path")
88
+ raw_segments: list[str] = []
89
+ start = 1
90
+ quoted = False
91
+ escaped = False
92
+ for index, char in enumerate(value[1:], start=1):
93
+ if quoted:
94
+ if escaped:
95
+ escaped = False
96
+ elif char == "\\":
97
+ escaped = True
98
+ elif char == '"':
99
+ quoted = False
100
+ elif char == '"':
101
+ quoted = True
102
+ elif char == "/":
103
+ raw_segments.append(value[start:index])
104
+ start = index + 1
105
+ if quoted:
106
+ raise AgentContractError("invalid_syntax", "unterminated path string", target="path")
107
+ raw_segments.append(value[start:])
108
+ if any(not segment for segment in raw_segments):
109
+ raise AgentContractError("invalid_syntax", "path contains an empty segment", target="path")
110
+ if len(raw_segments) > MAX_PATH_SEGMENTS:
111
+ raise AgentContractError("resource_limit", "path nesting exceeds limit", target="path")
112
+ segments: list[PathSegment] = []
113
+ for raw in raw_segments:
114
+ match = _SEGMENT_RE.fullmatch(raw)
115
+ if match is None:
116
+ raise AgentContractError("invalid_syntax", f"invalid path segment: {raw!r}", target="path")
117
+ kind = match.group("kind")
118
+ index_text = match.group("index")
119
+ if index_text is not None:
120
+ segments.append(PathSegment(kind=kind, index=int(index_text)))
121
+ continue
122
+ quoted_value = match.group("value")
123
+ try:
124
+ decoded = json.loads(quoted_value)
125
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
126
+ raise AgentContractError("invalid_syntax", "invalid path string literal", target="path") from exc
127
+ segments.append(
128
+ PathSegment(kind=kind, attribute=match.group("attribute"), value=decoded)
129
+ )
130
+ return SemanticPath(tuple(segments))
131
+
132
+
133
+ def canonicalize_path(value: str) -> str:
134
+ return parse_path(value).canonical
135
+
136
+
137
+ def indexed_segment(kind: str, index: int) -> PathSegment:
138
+ return PathSegment(kind=kind, index=index)
139
+
140
+
141
+ def identified_segment(kind: str, value: str, *, attribute: str = "id") -> PathSegment:
142
+ return PathSegment(kind=kind, attribute=attribute, value=value)
143
+
144
+
145
+ __all__ = [
146
+ "MAX_PATH_CHARS",
147
+ "MAX_PATH_SEGMENTS",
148
+ "PATH_ATTRIBUTES",
149
+ "PathSegment",
150
+ "SemanticPath",
151
+ "canonicalize_path",
152
+ "identified_segment",
153
+ "indexed_segment",
154
+ "parse_path",
155
+ ]
@@ -0,0 +1,230 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Bounded selector v1 parser and in-memory semantic-tree evaluator."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ import unicodedata
9
+ from collections.abc import Iterable, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+ from .model import (
14
+ MAX_QUERY_RESULTS,
15
+ MAX_SELECTOR_CHARS,
16
+ AgentContractError,
17
+ AgentNode,
18
+ SELECTOR_ATTRIBUTES,
19
+ SELECTOR_KINDS,
20
+ )
21
+
22
+ MAX_SELECTOR_STEPS = 8
23
+ MAX_SELECTOR_FILTERS = 4
24
+ _KIND_RE = re.compile(r"[a-z][a-z-]*")
25
+ _ATTR_RE = re.compile(r"\[([a-z]+)=(\"(?:[^\"\\]|\\.)*\"|[^\]\s]+)\]")
26
+ _CONTAINS_RE = re.compile(r":contains\((\"(?:[^\"\\]|\\.)*\")\)")
27
+
28
+
29
+ class QueryRecord(Protocol):
30
+ kind: str
31
+ path: str
32
+ parent_path: str | None
33
+ attributes: Mapping[str, str]
34
+ search_text: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class SelectorStep:
39
+ kind: str
40
+ attributes: tuple[tuple[str, str], ...] = ()
41
+ contains: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class SemanticSelector:
46
+ steps: tuple[SelectorStep, ...]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class QueryResult:
51
+ selector: str
52
+ revision: str
53
+ nodes: tuple[AgentNode, ...]
54
+ truncated: bool
55
+
56
+ def to_dict(self) -> dict[str, object]:
57
+ return {
58
+ "schemaVersion": "hwpx.agent-query-result/v1",
59
+ "selector": self.selector,
60
+ "revision": self.revision,
61
+ "count": len(self.nodes),
62
+ "truncated": self.truncated,
63
+ "nodes": [node.to_dict() for node in self.nodes],
64
+ }
65
+
66
+
67
+ def normalize_search_text(value: object) -> str:
68
+ text = unicodedata.normalize("NFKC", str(value or ""))
69
+ return " ".join(text.split()).casefold()
70
+
71
+
72
+ def _split_steps(selector: str) -> list[str]:
73
+ result: list[str] = []
74
+ start = 0
75
+ quoted = False
76
+ escaped = False
77
+ bracket_depth = 0
78
+ paren_depth = 0
79
+ for index, char in enumerate(selector):
80
+ if quoted:
81
+ if escaped:
82
+ escaped = False
83
+ elif char == "\\":
84
+ escaped = True
85
+ elif char == '"':
86
+ quoted = False
87
+ continue
88
+ if char == '"':
89
+ quoted = True
90
+ elif char == "[":
91
+ bracket_depth += 1
92
+ elif char == "]":
93
+ bracket_depth -= 1
94
+ elif char == "(":
95
+ paren_depth += 1
96
+ elif char == ")":
97
+ paren_depth -= 1
98
+ elif char == ">" and bracket_depth == 0 and paren_depth == 0:
99
+ result.append(selector[start:index].strip())
100
+ start = index + 1
101
+ if bracket_depth < 0 or paren_depth < 0:
102
+ raise AgentContractError("invalid_syntax", "unbalanced selector", target="selector")
103
+ if quoted or bracket_depth or paren_depth:
104
+ raise AgentContractError("invalid_syntax", "unbalanced selector", target="selector")
105
+ result.append(selector[start:].strip())
106
+ if any(not step for step in result):
107
+ raise AgentContractError("invalid_syntax", "empty selector step", target="selector")
108
+ return result
109
+
110
+
111
+ def _decode_literal(value: str) -> str:
112
+ if value.startswith('"'):
113
+ try:
114
+ decoded = json.loads(value)
115
+ except (ValueError, json.JSONDecodeError) as exc:
116
+ raise AgentContractError("invalid_syntax", "invalid selector string", target="selector") from exc
117
+ else:
118
+ decoded = value
119
+ if not isinstance(decoded, str) or not decoded or len(decoded) > 256:
120
+ raise AgentContractError("resource_limit", "selector value is invalid", target="selector")
121
+ return decoded
122
+
123
+
124
+ def _parse_step(raw: str) -> SelectorStep:
125
+ kind_match = _KIND_RE.match(raw)
126
+ if kind_match is None:
127
+ raise AgentContractError("invalid_syntax", "selector step requires a kind", target="selector")
128
+ kind = kind_match.group(0)
129
+ if kind not in SELECTOR_KINDS:
130
+ raise AgentContractError("unknown_kind", f"unknown selector kind: {kind}", target="selector")
131
+ cursor = kind_match.end()
132
+ attributes: list[tuple[str, str]] = []
133
+ contains: str | None = None
134
+ while cursor < len(raw):
135
+ attr_match = _ATTR_RE.match(raw, cursor)
136
+ if attr_match is not None:
137
+ name = attr_match.group(1)
138
+ if name not in SELECTOR_ATTRIBUTES:
139
+ raise AgentContractError(
140
+ "unknown_property", f"unsupported selector attribute: {name}", target="selector"
141
+ )
142
+ if any(existing == name for existing, _ in attributes):
143
+ raise AgentContractError("invalid_syntax", "duplicate selector attribute", target="selector")
144
+ attributes.append((name, _decode_literal(attr_match.group(2))))
145
+ if len(attributes) > MAX_SELECTOR_FILTERS:
146
+ raise AgentContractError("resource_limit", "too many selector filters", target="selector")
147
+ cursor = attr_match.end()
148
+ continue
149
+ contains_match = _CONTAINS_RE.match(raw, cursor)
150
+ if contains_match is not None and contains is None:
151
+ contains = _decode_literal(contains_match.group(1))
152
+ cursor = contains_match.end()
153
+ continue
154
+ raise AgentContractError(
155
+ "invalid_syntax", f"invalid selector near {raw[cursor:]!r}", target="selector"
156
+ )
157
+ return SelectorStep(kind=kind, attributes=tuple(attributes), contains=contains)
158
+
159
+
160
+ def parse_selector(value: str) -> SemanticSelector:
161
+ if not isinstance(value, str) or not value.strip():
162
+ raise AgentContractError("invalid_syntax", "selector must be non-empty", target="selector")
163
+ if len(value) > MAX_SELECTOR_CHARS:
164
+ raise AgentContractError("resource_limit", "selector is too long", target="selector")
165
+ raw_steps = _split_steps(value.strip())
166
+ if len(raw_steps) > MAX_SELECTOR_STEPS:
167
+ raise AgentContractError("resource_limit", "selector nesting exceeds limit", target="selector")
168
+ return SemanticSelector(tuple(_parse_step(step) for step in raw_steps))
169
+
170
+
171
+ def _matches(record: QueryRecord, step: SelectorStep) -> bool:
172
+ if record.kind != step.kind:
173
+ return False
174
+ for name, expected in step.attributes:
175
+ actual = record.attributes.get(name)
176
+ if actual is None or normalize_search_text(actual) != normalize_search_text(expected):
177
+ return False
178
+ if (
179
+ step.contains is not None
180
+ and normalize_search_text(step.contains) not in normalize_search_text(record.search_text)
181
+ ):
182
+ return False
183
+ return True
184
+
185
+
186
+ def evaluate_selector(
187
+ records: Sequence[QueryRecord] | Iterable[QueryRecord],
188
+ selector: SemanticSelector,
189
+ *,
190
+ limit: int,
191
+ ) -> tuple[list[QueryRecord], bool]:
192
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_QUERY_RESULTS:
193
+ raise AgentContractError(
194
+ "resource_limit", f"query limit must be 1..{MAX_QUERY_RESULTS}", target="limit"
195
+ )
196
+ ordered = list(records)
197
+ by_path = {record.path: record for record in ordered}
198
+ matches: list[QueryRecord] = []
199
+ for record in ordered:
200
+ if not _matches(record, selector.steps[-1]):
201
+ continue
202
+ cursor = record
203
+ valid = True
204
+ for step in reversed(selector.steps[:-1]):
205
+ if cursor.parent_path is None:
206
+ valid = False
207
+ break
208
+ parent = by_path.get(cursor.parent_path)
209
+ if parent is None or not _matches(parent, step):
210
+ valid = False
211
+ break
212
+ cursor = parent
213
+ if valid:
214
+ if len(matches) == limit:
215
+ return matches, True
216
+ matches.append(record)
217
+ return matches, False
218
+
219
+
220
+ __all__ = [
221
+ "MAX_SELECTOR_FILTERS",
222
+ "MAX_SELECTOR_STEPS",
223
+ "QueryRecord",
224
+ "QueryResult",
225
+ "SelectorStep",
226
+ "SemanticSelector",
227
+ "evaluate_selector",
228
+ "normalize_search_text",
229
+ "parse_selector",
230
+ ]
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Private, contract-neutral bindings for existing non-body stories.
3
+
4
+ The public semantic path grammar intentionally remains frozen at the Feature
5
+ 024 catalog. This module recognizes one narrower command-only seam: an
6
+ existing section header selected by its native id or page type. It does not
7
+ project headers into the public view, accept package paths, or expose XML.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from .model import AgentContractError
18
+ from .path import MAX_PATH_CHARS
19
+
20
+ HEADER_STORY_KIND = "header"
21
+ HEADER_STORY_EDITABLE_PROPERTIES = frozenset({"text"})
22
+ HEADER_PAGE_TYPES = frozenset({"BOTH", "EVEN", "ODD"})
23
+
24
+ _HEADER_STORY_RE = re.compile(
25
+ r"^/section\[(?P<section>0*[1-9][0-9]*)\]/header\["
26
+ r"@(?P<attribute>id|page-type)=(?P<value>\"(?:[^\"\\]|\\.)*\")\]$"
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class HeaderStoryPath:
32
+ """Parsed command-only path for one existing section header."""
33
+
34
+ section_index: int
35
+ attribute: str
36
+ value: str
37
+
38
+ @property
39
+ def kind(self) -> str:
40
+ return HEADER_STORY_KIND
41
+
42
+ @property
43
+ def canonical(self) -> str:
44
+ value = json.dumps(self.value, ensure_ascii=False, separators=(",", ":"))
45
+ return (
46
+ f"/section[{self.section_index}]/"
47
+ f"header[@{self.attribute}={value}]"
48
+ )
49
+
50
+ @property
51
+ def parent_path(self) -> str:
52
+ return f"/section[{self.section_index}]"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class HeaderStoryBinding:
57
+ """Request-local native binding for an existing logical header."""
58
+
59
+ path: str
60
+ parent_path: str
61
+ stable_id: str
62
+ native_id: str
63
+ page_type: str
64
+ section_index: int
65
+ native: Any
66
+ text: str
67
+
68
+ @property
69
+ def kind(self) -> str:
70
+ return HEADER_STORY_KIND
71
+
72
+ @property
73
+ def binding_key(self) -> str:
74
+ # Native ids are not promised to be document-global. Section scope is
75
+ # part of the logical binding even though the receipt exposes the same
76
+ # kind:id stable-id convention used by projected semantic nodes.
77
+ return f"{self.section_index}:{self.native_id}"
78
+
79
+
80
+ def try_parse_header_story_path(value: object) -> HeaderStoryPath | None:
81
+ """Return a private header path, or ``None`` for the public path parser.
82
+
83
+ Only exact supported forms are intercepted. Unsupported forms such as
84
+ ``header[1]`` deliberately continue through :func:`parse_path` and retain
85
+ the frozen ``unknown_kind`` behavior.
86
+ """
87
+
88
+ if not isinstance(value, str) or "/header[@" not in value:
89
+ return None
90
+ if len(value) > MAX_PATH_CHARS:
91
+ raise AgentContractError("resource_limit", "path is too long", target="path")
92
+ match = _HEADER_STORY_RE.fullmatch(value)
93
+ if match is None:
94
+ return None
95
+ try:
96
+ decoded = json.loads(match.group("value"))
97
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
98
+ raise AgentContractError(
99
+ "invalid_syntax", "invalid header story path string", target="path"
100
+ ) from exc
101
+ if not isinstance(decoded, str) or not decoded or len(decoded) > 256:
102
+ raise AgentContractError(
103
+ "resource_limit", "header story selector is invalid", target="path"
104
+ )
105
+ attribute = match.group("attribute")
106
+ if attribute == "page-type" and decoded not in HEADER_PAGE_TYPES:
107
+ raise AgentContractError(
108
+ "invalid_syntax",
109
+ "header page type must be BOTH, EVEN, or ODD",
110
+ target="path",
111
+ )
112
+ return HeaderStoryPath(
113
+ section_index=int(match.group("section")),
114
+ attribute=attribute,
115
+ value=decoded,
116
+ )
117
+
118
+
119
+ def parse_header_story_path(value: str) -> HeaderStoryPath:
120
+ """Parse an exact private header path for focused internal tests."""
121
+
122
+ parsed = try_parse_header_story_path(value)
123
+ if parsed is None:
124
+ raise AgentContractError(
125
+ "invalid_syntax", "unsupported header story path", target="path"
126
+ )
127
+ return parsed
128
+
129
+
130
+ def resolve_header_story(document: Any, path: HeaderStoryPath) -> HeaderStoryBinding:
131
+ """Resolve one unique direct logical header without scanning descendants."""
132
+
133
+ try:
134
+ sections = document.sections
135
+ except (AttributeError, TypeError, ValueError) as exc:
136
+ raise AgentContractError(
137
+ "unsupported_content", "document section structure is unavailable", target=path.canonical
138
+ ) from exc
139
+ if path.section_index > len(sections):
140
+ raise AgentContractError(
141
+ "not_found", "header story section does not exist", target=path.canonical
142
+ )
143
+ section = sections[path.section_index - 1]
144
+ try:
145
+ headers = tuple(section.properties.headers)
146
+ if path.attribute == "id":
147
+ matches = [header for header in headers if header.id == path.value]
148
+ else:
149
+ matches = [
150
+ header for header in headers if header.apply_page_type == path.value
151
+ ]
152
+ except (AttributeError, TypeError, ValueError) as exc:
153
+ raise AgentContractError(
154
+ "unsupported_content", "section header structure is invalid", target=path.canonical
155
+ ) from exc
156
+
157
+ if not matches:
158
+ raise AgentContractError(
159
+ "not_found", f"existing header story not found: {path.canonical}", target=path.canonical
160
+ )
161
+ if len(matches) > 1:
162
+ raise AgentContractError(
163
+ "ambiguous_target",
164
+ f"header story selector is not unique: {path.canonical}",
165
+ target=path.canonical,
166
+ )
167
+
168
+ header = matches[0]
169
+ try:
170
+ native_id = header.id
171
+ page_type = header.apply_page_type
172
+ text = header.text
173
+ except (AttributeError, TypeError, ValueError) as exc:
174
+ raise AgentContractError(
175
+ "unsupported_content", "header story cannot be read safely", target=path.canonical
176
+ ) from exc
177
+ if not native_id or len(native_id) > 256:
178
+ raise AgentContractError(
179
+ "unsupported_content", "header story has no bounded native identity", target=path.canonical
180
+ )
181
+ if page_type not in HEADER_PAGE_TYPES:
182
+ raise AgentContractError(
183
+ "unsupported_content", "header story has an unsupported page type", target=path.canonical
184
+ )
185
+
186
+ return HeaderStoryBinding(
187
+ path=path.canonical,
188
+ parent_path=path.parent_path,
189
+ stable_id=f"header:{native_id}",
190
+ native_id=native_id,
191
+ page_type=page_type,
192
+ section_index=path.section_index,
193
+ native=header,
194
+ text=text,
195
+ )
196
+
197
+
198
+ __all__ = [
199
+ "HEADER_PAGE_TYPES",
200
+ "HEADER_STORY_EDITABLE_PROPERTIES",
201
+ "HEADER_STORY_KIND",
202
+ "HeaderStoryBinding",
203
+ "HeaderStoryPath",
204
+ "parse_header_story_path",
205
+ "resolve_header_story",
206
+ "try_parse_header_story_path",
207
+ ]