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,369 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Authenticated private HTTP transport for the durable render queue."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import ipaddress
10
+ import json
11
+ import socket
12
+ import ssl
13
+ import time
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+ from urllib.error import HTTPError
18
+ from urllib.parse import quote, urlparse
19
+ from urllib.request import Request
20
+
21
+ from ..network_policy import NetworkPolicy, open_url
22
+ from .render_queue import DurableRenderQueue, RenderQueueError, sign_submission
23
+ from .rendering import RenderJobV2, RenderReceiptV2
24
+
25
+
26
+ def request_signature(
27
+ secret: bytes, method: str, path: str, timestamp: str, body: bytes
28
+ ) -> str:
29
+ digest = hashlib.sha256(body).hexdigest()
30
+ message = f"{method.upper()}\n{path}\n{timestamp}\n{digest}".encode()
31
+ return "sha256=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
32
+
33
+
34
+ class RemoteRenderClientV2:
35
+ def __init__(
36
+ self,
37
+ base_url: str,
38
+ *,
39
+ secret: bytes,
40
+ transport_auth: Literal["mtls", "signed_https"] = "mtls",
41
+ ca_file: Path | None = None,
42
+ client_certfile: Path | None = None,
43
+ client_keyfile: Path | None = None,
44
+ timeout: float = 30,
45
+ allow_insecure_loopback: bool = False,
46
+ allow_private_network: bool = False,
47
+ ) -> None:
48
+ parsed = urlparse(base_url)
49
+ if transport_auth not in {"mtls", "signed_https"}:
50
+ raise ValueError("transport_auth must be 'mtls' or 'signed_https'")
51
+ insecure_loopback = bool(
52
+ allow_insecure_loopback
53
+ and parsed.scheme == "http"
54
+ and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
55
+ )
56
+ if parsed.scheme != "https" and not insecure_loopback:
57
+ raise ValueError(
58
+ "render queue URL must use HTTPS (HTTP is test-only loopback)"
59
+ )
60
+ if transport_auth == "mtls" and not insecure_loopback:
61
+ if not ca_file or not client_certfile or not client_keyfile:
62
+ raise ValueError(
63
+ "mTLS requires a CA trust root, client certificate, and client key"
64
+ )
65
+ context = ssl.create_default_context(
66
+ ssl.Purpose.SERVER_AUTH, cafile=str(ca_file)
67
+ )
68
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
69
+ context.load_cert_chain(str(client_certfile), str(client_keyfile))
70
+ elif transport_auth == "signed_https":
71
+ if client_certfile or client_keyfile:
72
+ raise ValueError("signed_https does not accept mTLS client credentials")
73
+ context = ssl.create_default_context(
74
+ ssl.Purpose.SERVER_AUTH, cafile=str(ca_file) if ca_file else None
75
+ )
76
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
77
+ else: # explicit test-only HTTP loopback in mTLS mode
78
+ context = None
79
+ self.base_url = base_url.rstrip("/")
80
+ self.secret = secret
81
+ self.transport_auth = transport_auth
82
+ self._ssl_context = context
83
+ self.timeout = timeout
84
+ self._network_policy = NetworkPolicy(
85
+ allow_private_network=allow_private_network or allow_insecure_loopback,
86
+ allow_http=insecure_loopback,
87
+ )
88
+ self._network_policy.validate_url(self.base_url)
89
+
90
+ def _request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ payload: dict[str, Any] | None = None,
95
+ *,
96
+ raw: bool = False,
97
+ ):
98
+ body = (
99
+ json.dumps(payload, separators=(",", ":")).encode()
100
+ if payload is not None
101
+ else b""
102
+ )
103
+ timestamp = str(int(time.time()))
104
+ request = Request(
105
+ self.base_url + path,
106
+ data=body if method != "GET" else None,
107
+ method=method,
108
+ headers={
109
+ "Content-Type": "application/json",
110
+ "X-HWPX-Timestamp": timestamp,
111
+ "X-HWPX-Signature": request_signature(
112
+ self.secret, method, path, timestamp, body
113
+ ),
114
+ },
115
+ )
116
+ try:
117
+ with open_url(
118
+ request,
119
+ policy=self._network_policy,
120
+ timeout=self.timeout,
121
+ context=self._ssl_context,
122
+ ) as response:
123
+ data = response.read()
124
+ except HTTPError as exc:
125
+ raise RenderQueueError(
126
+ f"REMOTE_HTTP_{exc.code}", "remote render queue request failed"
127
+ ) from exc
128
+ return data if raw else json.loads(data)
129
+
130
+ def capabilities(self) -> dict[str, object]:
131
+ return self._request("GET", "/v1/health")
132
+
133
+ def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
134
+ response = self._request(
135
+ "POST",
136
+ "/v1/jobs",
137
+ {
138
+ "job": job.model_dump(mode="json"),
139
+ "filename": source_path.name,
140
+ "contentBase64": base64.b64encode(source_path.read_bytes()).decode(
141
+ "ascii"
142
+ ),
143
+ },
144
+ )
145
+ return RenderReceiptV2.model_validate(response["receipt"])
146
+
147
+ def get(self, job_id: str) -> RenderReceiptV2:
148
+ response = self._request("GET", f"/v1/jobs/{quote(job_id, safe='')}")
149
+ return RenderReceiptV2.model_validate(response["receipt"])
150
+
151
+ def cancel(self, job_id: str) -> RenderReceiptV2:
152
+ response = self._request(
153
+ "POST", f"/v1/jobs/{quote(job_id, safe='')}/cancel", {}
154
+ )
155
+ return RenderReceiptV2.model_validate(response["receipt"])
156
+
157
+ def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
158
+ digest = content_hash.removeprefix("sha256:")
159
+ data = self._request(
160
+ "GET", f"/v1/jobs/{quote(job_id, safe='')}/artifacts/{digest}", raw=True
161
+ )
162
+ if "sha256:" + hashlib.sha256(data).hexdigest() != content_hash:
163
+ raise RenderQueueError(
164
+ "ARTIFACT_HASH_MISMATCH", "downloaded artifact hash mismatch"
165
+ )
166
+ return data
167
+
168
+
169
+ def make_queue_handler(
170
+ queue: DurableRenderQueue, *, secret: bytes, max_clock_skew_seconds: int = 300
171
+ ):
172
+ class QueueHandler(BaseHTTPRequestHandler):
173
+ server_version = "HWPXRenderQueue/1"
174
+
175
+ def log_message(self, format: str, *args: object) -> None:
176
+ return None # never log paths, query strings, headers, or document metadata
177
+
178
+ def _body(self) -> bytes:
179
+ length = int(self.headers.get("Content-Length", "0"))
180
+ if length > queue.policy.max_input_bytes * 2:
181
+ raise RenderQueueError(
182
+ "REQUEST_TOO_LARGE", "remote request body exceeds limit"
183
+ )
184
+ return self.rfile.read(length)
185
+
186
+ def _authenticate(self, body: bytes) -> None:
187
+ timestamp = self.headers.get("X-HWPX-Timestamp", "")
188
+ signature = self.headers.get("X-HWPX-Signature", "")
189
+ try:
190
+ fresh = abs(time.time() - int(timestamp)) <= max_clock_skew_seconds
191
+ except ValueError:
192
+ fresh = False
193
+ expected = request_signature(
194
+ secret, self.command, self.path, timestamp, body
195
+ )
196
+ if not fresh or not hmac.compare_digest(expected, signature):
197
+ raise RenderQueueError(
198
+ "REMOTE_AUTH_REJECTED", "remote request authentication rejected"
199
+ )
200
+
201
+ def _json(self, status: int, value: dict[str, Any]) -> None:
202
+ data = json.dumps(value, ensure_ascii=False).encode()
203
+ self.send_response(status)
204
+ self.send_header("Content-Type", "application/json")
205
+ self.send_header("Content-Length", str(len(data)))
206
+ self.end_headers()
207
+ self.wfile.write(data)
208
+
209
+ def _dispatch(self) -> None:
210
+ body = self._body()
211
+ self._authenticate(body)
212
+ parts = [part for part in self.path.split("/") if part]
213
+ if self.command == "GET" and self.path == "/v1/health":
214
+ self._json(200, queue.health())
215
+ return
216
+ if self.command == "POST" and self.path == "/v1/jobs":
217
+ payload = json.loads(body)
218
+ job = RenderJobV2.model_validate(payload["job"])
219
+ content = base64.b64decode(payload["contentBase64"], validate=True)
220
+ receipt = queue.submit(
221
+ job,
222
+ content,
223
+ signature=sign_submission(secret, job),
224
+ principal_id="remote-mcp-client",
225
+ filename=payload["filename"],
226
+ )
227
+ self._json(200, {"receipt": receipt.model_dump(mode="json")})
228
+ return
229
+ if (
230
+ len(parts) == 3
231
+ and parts[:2] == ["v1", "jobs"]
232
+ and self.command == "GET"
233
+ ):
234
+ self._json(
235
+ 200, {"receipt": queue.get(parts[2]).model_dump(mode="json")}
236
+ )
237
+ return
238
+ if (
239
+ len(parts) == 4
240
+ and parts[:2] == ["v1", "jobs"]
241
+ and parts[3] == "cancel"
242
+ and self.command == "POST"
243
+ ):
244
+ self._json(
245
+ 200, {"receipt": queue.cancel(parts[2]).model_dump(mode="json")}
246
+ )
247
+ return
248
+ if (
249
+ len(parts) == 5
250
+ and parts[:2] == ["v1", "jobs"]
251
+ and parts[3] == "artifacts"
252
+ and self.command == "GET"
253
+ ):
254
+ receipt = queue.get(parts[2])
255
+ content_hash = "sha256:" + parts[4]
256
+ if content_hash not in {
257
+ item.content_hash for item in receipt.artifacts
258
+ }:
259
+ raise RenderQueueError(
260
+ "ARTIFACT_NOT_IN_RECEIPT",
261
+ "artifact is not bound to job receipt",
262
+ )
263
+ data = queue.content.path_for(content_hash).read_bytes()
264
+ self.send_response(200)
265
+ self.send_header("Content-Type", "application/octet-stream")
266
+ self.send_header("Content-Length", str(len(data)))
267
+ self.end_headers()
268
+ self.wfile.write(data)
269
+ return
270
+ self._json(404, {"errorCode": "NOT_FOUND"})
271
+
272
+ def do_GET(self) -> None:
273
+ self._safe_dispatch()
274
+
275
+ def do_POST(self) -> None:
276
+ self._safe_dispatch()
277
+
278
+ def _safe_dispatch(self) -> None:
279
+ try:
280
+ self._dispatch()
281
+ except (
282
+ RenderQueueError,
283
+ KeyError,
284
+ ValueError,
285
+ json.JSONDecodeError,
286
+ ) as exc:
287
+ code = getattr(exc, "code", "BAD_REQUEST")
288
+ self._json(
289
+ 401 if code == "REMOTE_AUTH_REJECTED" else 400, {"errorCode": code}
290
+ )
291
+
292
+ return QueueHandler
293
+
294
+
295
+ def serve_private_queue(
296
+ queue: DurableRenderQueue,
297
+ *,
298
+ secret: bytes,
299
+ host: str,
300
+ port: int,
301
+ certfile: Path | None = None,
302
+ keyfile: Path | None = None,
303
+ client_ca_file: Path | None = None,
304
+ allow_insecure_loopback: bool = False,
305
+ allow_non_private_test_bind: bool = False,
306
+ ) -> ThreadingHTTPServer:
307
+ if (
308
+ queue.policy.private_network_required
309
+ and not allow_non_private_test_bind
310
+ and not _is_private_bind_host(host)
311
+ ):
312
+ raise ValueError("render queue must bind only to a private or loopback address")
313
+ insecure_loopback = bool(
314
+ allow_insecure_loopback and host in {"127.0.0.1", "localhost", "::1"}
315
+ )
316
+ if not certfile or not keyfile:
317
+ if not insecure_loopback:
318
+ raise ValueError(
319
+ "TLS certificate and key are required outside test-only loopback"
320
+ )
321
+ if (
322
+ queue.policy.transport_auth == "mtls"
323
+ and not insecure_loopback
324
+ and not client_ca_file
325
+ ):
326
+ raise ValueError(
327
+ "mTLS requires a CA trust root for client-certificate verification"
328
+ )
329
+ if queue.policy.transport_auth == "signed_https" and client_ca_file:
330
+ raise ValueError("signed_https must not be configured as implicit mTLS")
331
+ server = ThreadingHTTPServer((host, port), make_queue_handler(queue, secret=secret))
332
+ if certfile and keyfile:
333
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
334
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
335
+ context.load_cert_chain(str(certfile), str(keyfile))
336
+ if queue.policy.transport_auth == "mtls":
337
+ context.verify_mode = ssl.CERT_REQUIRED
338
+ context.load_verify_locations(cafile=str(client_ca_file))
339
+ server.socket = context.wrap_socket(server.socket, server_side=True)
340
+ return server
341
+
342
+
343
+ def _is_private_bind_host(host: str) -> bool:
344
+ """Fail closed unless every address represented by *host* is private/loopback."""
345
+
346
+ try:
347
+ addresses = {ipaddress.ip_address(host)}
348
+ except ValueError:
349
+ try:
350
+ addresses = {
351
+ ipaddress.ip_address(item[4][0].split("%", 1)[0])
352
+ for item in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
353
+ }
354
+ except (OSError, ValueError):
355
+ return False
356
+ return bool(addresses) and all(
357
+ (address.is_private or address.is_loopback)
358
+ and not address.is_unspecified
359
+ and not address.is_multicast
360
+ for address in addresses
361
+ )
362
+
363
+
364
+ __all__ = [
365
+ "RemoteRenderClientV2",
366
+ "make_queue_handler",
367
+ "request_signature",
368
+ "serve_private_queue",
369
+ ]
@@ -0,0 +1,206 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Transport-neutral render boundary consumed by the workflow engine.
3
+
4
+ The base workflow never claims that this interface rendered a document. A later
5
+ real-Hancom backend and provenance receipts behind this frozen contract.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
14
+
15
+ # Shared v2 contracts live in the leaf module that removed the import cycle; they are
16
+ # re-exported here so existing importers keep working unchanged.
17
+ from .render_contracts import ( # noqa: F401 - compatibility re-exports
18
+ RENDER_SCHEMA_VERSION_V2,
19
+ SHA256_PATTERN,
20
+ RenderArtifactKind,
21
+ RenderArtifactV2,
22
+ RenderJobV2,
23
+ RenderReceiptV2,
24
+ RenderStatus,
25
+ sign_submission,
26
+ )
27
+
28
+
29
+ RENDER_SCHEMA_VERSION = "hwpx.render.v1"
30
+
31
+
32
+ class RenderJob(BaseModel):
33
+ model_config = ConfigDict(extra="forbid", frozen=True)
34
+
35
+ schema_version: str = RENDER_SCHEMA_VERSION
36
+ job_id: str = Field(min_length=8, max_length=200)
37
+ workflow_id: str = Field(min_length=8, max_length=200)
38
+ source_path: str
39
+ source_content_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
40
+ requested_backend: str = "real_hancom"
41
+ dpi: int = Field(default=144, ge=72, le=600)
42
+
43
+
44
+ class RenderReceipt(BaseModel):
45
+ model_config = ConfigDict(extra="forbid", frozen=True)
46
+
47
+ schema_version: str = RENDER_SCHEMA_VERSION
48
+ job_id: str
49
+ status: RenderStatus
50
+ backend: str | None = None
51
+ hancom_build: str | None = None
52
+ page_artifacts: tuple[str, ...] = ()
53
+ output_content_hash: str | None = None
54
+ render_checked: bool = False
55
+ error_code: str | None = None
56
+
57
+ @model_validator(mode="after")
58
+ def truthful_render_claim(self) -> "RenderReceipt":
59
+ if self.render_checked and (
60
+ self.status != RenderStatus.SUCCEEDED
61
+ or not self.backend
62
+ or not self.hancom_build
63
+ or not self.page_artifacts
64
+ ):
65
+ raise ValueError(
66
+ "render_checked requires succeeded status, backend, Hancom build, and page artifacts"
67
+ )
68
+ if self.status == RenderStatus.SUCCEEDED and not self.output_content_hash:
69
+ raise ValueError("successful render receipt requires output_content_hash")
70
+ return self
71
+
72
+
73
+ @runtime_checkable
74
+ class RenderBackend(Protocol):
75
+ """Stable provider boundary; implementations may be local or remote."""
76
+
77
+ def capabilities(self) -> dict[str, object]: ...
78
+
79
+ def submit(self, job: RenderJob) -> RenderReceipt: ...
80
+
81
+ def get(self, job_id: str) -> RenderReceipt: ...
82
+
83
+ def cancel(self, job_id: str) -> RenderReceipt: ...
84
+
85
+
86
+ @runtime_checkable
87
+ class RenderClientV2(Protocol):
88
+ def capabilities(self) -> dict[str, object]: ...
89
+ def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2: ...
90
+ def get(self, job_id: str) -> RenderReceiptV2: ...
91
+ def cancel(self, job_id: str) -> RenderReceiptV2: ...
92
+ def fetch_artifact(self, job_id: str, content_hash: str) -> bytes: ...
93
+
94
+
95
+ class NullRenderClientV2:
96
+ def capabilities(self) -> dict[str, object]:
97
+ return {
98
+ "schemaVersion": "hwpx.render-health.v1", "available": False,
99
+ "degraded": True, "degradedReason": "NOT_CONFIGURED", "queueDepth": 0,
100
+ "runningJobs": 0, "oldestQueuedAgeSeconds": 0.0,
101
+ "lastSuccessfulRealRender": None, "workerVersion": None, "hancomBuild": None,
102
+ }
103
+
104
+ def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
105
+ del source_path
106
+ return RenderReceiptV2(
107
+ job_id=job.job_id, workflow_id=job.workflow_id,
108
+ input_content_hash=job.source_content_hash, status=RenderStatus.UNAVAILABLE,
109
+ queued_at=job.submitted_at, completed_at=job.submitted_at,
110
+ terminal_reason="REAL_HANCOM_BACKEND_UNAVAILABLE",
111
+ )
112
+
113
+ def get(self, job_id: str) -> RenderReceiptV2:
114
+ raise KeyError(job_id)
115
+
116
+ def cancel(self, job_id: str) -> RenderReceiptV2:
117
+ raise KeyError(job_id)
118
+
119
+ def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
120
+ raise KeyError(job_id)
121
+
122
+
123
+ class QueueRenderClientV2:
124
+ """Trusted local client for the authenticated private durable queue."""
125
+
126
+ def __init__(
127
+ self,
128
+ queue: object,
129
+ *,
130
+ secret: bytes,
131
+ # Compatibility-preserved through 6.x: this value participates in
132
+ # signed queue authorization, so renaming it would strand queued jobs.
133
+ principal_id: str = "hwpx-mcp-server",
134
+ ) -> None:
135
+ self.queue = queue
136
+ self.secret = secret
137
+ self.principal_id = principal_id
138
+
139
+ def capabilities(self) -> dict[str, object]:
140
+ return self.queue.health()
141
+
142
+ def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
143
+ return self.queue.submit(
144
+ job, source_path.read_bytes(), signature=sign_submission(self.secret, job),
145
+ principal_id=self.principal_id, filename=source_path.name,
146
+ )
147
+
148
+ def get(self, job_id: str) -> RenderReceiptV2:
149
+ return self.queue.get(job_id)
150
+
151
+ def cancel(self, job_id: str) -> RenderReceiptV2:
152
+ return self.queue.cancel(job_id)
153
+
154
+ def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
155
+ receipt = self.queue.get(job_id)
156
+ if content_hash not in {item.content_hash for item in receipt.artifacts}:
157
+ raise KeyError(content_hash)
158
+ return self.queue.content.path_for(content_hash).read_bytes()
159
+
160
+
161
+ class NullRenderBackend:
162
+ """Honest default: render evidence is unavailable, never silently true."""
163
+
164
+ def capabilities(self) -> dict[str, object]:
165
+ return {
166
+ "schemaVersion": RENDER_SCHEMA_VERSION,
167
+ "available": False,
168
+ "backend": "none",
169
+ "renderChecked": False,
170
+ }
171
+
172
+ def submit(self, job: RenderJob) -> RenderReceipt:
173
+ return self._unavailable(job.job_id)
174
+
175
+ def get(self, job_id: str) -> RenderReceipt:
176
+ return self._unavailable(job_id)
177
+
178
+ def cancel(self, job_id: str) -> RenderReceipt:
179
+ return RenderReceipt(job_id=job_id, status=RenderStatus.CANCELLED, error_code="NO_RENDER_BACKEND")
180
+
181
+ @staticmethod
182
+ def _unavailable(job_id: str) -> RenderReceipt:
183
+ return RenderReceipt(
184
+ job_id=job_id,
185
+ status=RenderStatus.UNAVAILABLE,
186
+ render_checked=False,
187
+ error_code="REAL_HANCOM_BACKEND_UNAVAILABLE",
188
+ )
189
+
190
+
191
+ __all__ = [
192
+ "RENDER_SCHEMA_VERSION",
193
+ "RENDER_SCHEMA_VERSION_V2",
194
+ "NullRenderBackend",
195
+ "NullRenderClientV2",
196
+ "QueueRenderClientV2",
197
+ "RenderArtifactKind",
198
+ "RenderArtifactV2",
199
+ "RenderBackend",
200
+ "RenderClientV2",
201
+ "RenderJob",
202
+ "RenderJobV2",
203
+ "RenderReceipt",
204
+ "RenderReceiptV2",
205
+ "RenderStatus",
206
+ ]