crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,637 @@
1
+ """Optional local BabelDOC engine discovery and installation.
2
+
3
+ The document engine deliberately lives outside the Gateway environment. The
4
+ default installer gets BabelDOC from its official Python package and asks
5
+ BabelDOC to download and verify its own runtime assets. An offline release
6
+ bundle remains supported for air-gapped environments. After installation PDF
7
+ processing never contacts BabelDOC, Hugging Face, or ModelScope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import platform
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import sysconfig
21
+ import tempfile
22
+ import urllib.parse
23
+ import zipfile
24
+ from pathlib import Path, PurePosixPath, PureWindowsPath
25
+ from typing import Any, Callable
26
+
27
+ import httpx
28
+
29
+ from crabcode_core.subprocess_utils import managed_process_command, subprocess_group_options
30
+ from crabcode_core.path_validation import validate_path_component
31
+ from crabcode_core.filesystem import replace_with_retry
32
+ from crabcode_core.paths import get_config_home
33
+ from crabcode_gateway import __version__ as CRABCODE_VERSION
34
+
35
+
36
+ BABELDOC_VERSION = "0.6.4"
37
+ ENGINE_PROTOCOL_VERSION = 1
38
+ ENGINE_MANIFEST_NAME = "engine.json"
39
+ ENGINE_BUNDLE_MANIFEST_NAME = "engine-manifest.json"
40
+ ENGINE_WORKER_NAME = "document-worker.py"
41
+ MAX_ENGINE_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024
42
+ ESTIMATED_ENGINE_BUNDLE_BYTES = 550 * 1024 * 1024
43
+ ProgressCallback = Callable[[str], None]
44
+
45
+
46
+ def _run_text_command(
47
+ command: list[str],
48
+ **kwargs: Any,
49
+ ) -> subprocess.CompletedProcess[str]:
50
+ """Run an engine helper with deterministic UTF-8 and no Windows popup."""
51
+ environment = os.environ.copy()
52
+ environment["PYTHONUTF8"] = "1"
53
+ environment["PYTHONIOENCODING"] = "utf-8"
54
+ return subprocess.run(
55
+ managed_process_command(command),
56
+ text=True,
57
+ encoding="utf-8",
58
+ errors="replace",
59
+ env=environment,
60
+ **subprocess_group_options(),
61
+ **kwargs,
62
+ )
63
+
64
+
65
+ def document_engine_root() -> Path:
66
+ override = os.environ.get("CRABCODE_DOCUMENT_ENGINE_HOME", "").strip()
67
+ if override:
68
+ return Path(override).expanduser().resolve(strict=False)
69
+ return get_config_home() / "engines" / "babeldoc" / BABELDOC_VERSION
70
+
71
+
72
+ def _venv_python(root: Path) -> Path:
73
+ if os.name == "nt":
74
+ return root / "venv" / "Scripts" / "python.exe"
75
+ return root / "venv" / "bin" / "python"
76
+
77
+
78
+ def _worker_path(root: Path) -> Path:
79
+ return root / ENGINE_WORKER_NAME
80
+
81
+
82
+ def _platform_tag() -> str:
83
+ raw = sysconfig.get_platform().replace("-", "_").replace(".", "_")
84
+ machine = platform.machine().lower().replace("-", "_")
85
+ if machine and machine not in raw:
86
+ raw = f"{raw}_{machine}"
87
+ return re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower()
88
+
89
+
90
+ def _python_tag() -> str:
91
+ return f"cp{sys.version_info.major}{sys.version_info.minor}"
92
+
93
+
94
+ def _read_json(path: Path) -> dict[str, Any] | None:
95
+ try:
96
+ value = json.loads(path.read_text(encoding="utf-8"))
97
+ except (OSError, json.JSONDecodeError):
98
+ return None
99
+ return value if isinstance(value, dict) else None
100
+
101
+
102
+ def _verify_asset_manifest(root: Path) -> str | None:
103
+ manifest_path = root / "assets" / "asset-manifest.json"
104
+ manifest = _read_json(manifest_path)
105
+ files = manifest.get("files") if isinstance(manifest, dict) else None
106
+ if not isinstance(files, list) or not files:
107
+ return "高精度 PDF 引擎资源清单缺失或损坏"
108
+ assets = manifest_path.parent.resolve(strict=True)
109
+ for item in files:
110
+ if not isinstance(item, dict):
111
+ return "高精度 PDF 引擎资源清单损坏"
112
+ relative = item.get("path")
113
+ digest = item.get("sha256")
114
+ if not isinstance(relative, str) or not re.fullmatch(r"[0-9a-f]{64}", str(digest)):
115
+ return "高精度 PDF 引擎资源清单损坏"
116
+ try:
117
+ candidate = assets / _safe_bundle_member(relative)
118
+ except ValueError:
119
+ return "高精度 PDF 引擎资源清单损坏"
120
+ try:
121
+ path = candidate.resolve(strict=True)
122
+ except OSError:
123
+ return f"高精度 PDF 引擎资源缺失:{relative}"
124
+ if assets not in path.parents or not path.is_file() or _sha256(path) != digest:
125
+ return f"高精度 PDF 引擎资源校验失败:{relative}"
126
+ return None
127
+
128
+
129
+ def _verify_engine_runtime(root: Path) -> str | None:
130
+ try:
131
+ check = _run_text_command(
132
+ [
133
+ str(_venv_python(root)),
134
+ "-c",
135
+ (
136
+ "import importlib.metadata, importlib.util; "
137
+ "print(importlib.metadata.version('BabelDOC')); "
138
+ f"assert {str(_worker_path(root).is_file())} or "
139
+ "importlib.util.find_spec('crabcode_gateway.document_worker')"
140
+ ),
141
+ ],
142
+ capture_output=True,
143
+ timeout=10,
144
+ )
145
+ except (OSError, subprocess.SubprocessError):
146
+ return "高精度 PDF 引擎运行环境损坏"
147
+ if check.returncode != 0 or check.stdout.strip().splitlines()[-1:] != [BABELDOC_VERSION]:
148
+ return "高精度 PDF 引擎运行环境版本不匹配"
149
+ return None
150
+
151
+
152
+ def document_engine_status() -> dict[str, Any]:
153
+ root = document_engine_root()
154
+ install_state = _read_json(root.parent / ".install-state.json")
155
+ manifest = _read_json(root / ENGINE_MANIFEST_NAME)
156
+ status = "not_installed"
157
+ detail = "高精度 PDF 引擎尚未安装"
158
+ ready = False
159
+ installed_version: str | None = None
160
+ asset_version: str | None = None
161
+ if root.exists() and manifest is None:
162
+ status = "broken"
163
+ detail = "高精度 PDF 引擎清单缺失或损坏"
164
+ elif manifest is not None:
165
+ installed_version = str(manifest.get("engine_version") or "") or None
166
+ asset_version = str(manifest.get("asset_version") or "") or None
167
+ if manifest.get("protocol_version") != ENGINE_PROTOCOL_VERSION:
168
+ status = "upgrade_required"
169
+ detail = "高精度 PDF 引擎协议版本不兼容"
170
+ elif installed_version != BABELDOC_VERSION:
171
+ status = "upgrade_required"
172
+ detail = "高精度 PDF 引擎需要升级"
173
+ elif not _venv_python(root).is_file():
174
+ status = "broken"
175
+ detail = "高精度 PDF 引擎的 Python 环境不完整"
176
+ elif not (root / "assets" / "asset-manifest.json").is_file():
177
+ status = "broken"
178
+ detail = "高精度 PDF 引擎资源不完整"
179
+ else:
180
+ runtime_error = _verify_engine_runtime(root)
181
+ asset_error = _verify_asset_manifest(root) if runtime_error is None else None
182
+ if runtime_error:
183
+ status = "broken"
184
+ detail = runtime_error
185
+ elif asset_error:
186
+ status = "broken"
187
+ detail = asset_error
188
+ else:
189
+ status = "ready"
190
+ detail = "高精度 PDF 引擎可以使用"
191
+ ready = True
192
+ result = {
193
+ "status": status,
194
+ "available": ready,
195
+ "engine": "precise",
196
+ "version": installed_version or BABELDOC_VERSION,
197
+ "installed_version": installed_version,
198
+ "asset_version": asset_version,
199
+ "protocol_version": ENGINE_PROTOCOL_VERSION,
200
+ "python_supported": (3, 10) <= sys.version_info[:2] < (3, 14),
201
+ "managed": True,
202
+ "install_root": str(root),
203
+ "download_bytes": (
204
+ manifest.get("download_bytes") or manifest.get("bundle_bytes")
205
+ if manifest
206
+ else ESTIMATED_ENGINE_BUNDLE_BYTES
207
+ ),
208
+ "download_estimated": manifest is None,
209
+ "install_source": manifest.get("install_source") if manifest else "official",
210
+ "install_command": "crabcode document-engine install",
211
+ "remove_command": "crabcode document-engine remove --yes",
212
+ "detail": detail,
213
+ }
214
+ if isinstance(install_state, dict) and install_state.get("status") in {"downloading", "verifying", "installing", "broken"}:
215
+ result["status"] = install_state["status"]
216
+ result["available"] = False
217
+ result["detail"] = str(install_state.get("detail") or detail)
218
+ return result
219
+
220
+
221
+ def document_engine_python() -> Path:
222
+ status = document_engine_status()
223
+ if not status["available"]:
224
+ raise RuntimeError(str(status["detail"]))
225
+ return _venv_python(document_engine_root())
226
+
227
+
228
+ def document_engine_worker_command() -> list[str]:
229
+ """Return the isolated worker command, including old bundle compatibility."""
230
+ python = document_engine_python()
231
+ # Keep BabelDOC and its assets in the managed virtual environment, but run
232
+ # the worker shipped with the current Gateway. Worker-only fixes then take
233
+ # effect after an app update without downloading the engine again.
234
+ current_worker = Path(__file__).with_name("document_worker.py")
235
+ if current_worker.is_file():
236
+ return [str(python), str(current_worker)]
237
+ installed_worker = _worker_path(document_engine_root())
238
+ if installed_worker.is_file():
239
+ return [str(python), str(installed_worker)]
240
+ return [str(python), "-m", "crabcode_gateway.document_worker"]
241
+
242
+
243
+ def select_document_translation_engine(
244
+ requested: str,
245
+ precise_status: dict[str, Any] | None = None,
246
+ ) -> str:
247
+ if requested not in {"auto", "legacy", "precise"}:
248
+ raise ValueError("invalid document translation engine")
249
+ if requested == "legacy":
250
+ return "legacy"
251
+ status = precise_status or document_engine_status()
252
+ if requested == "precise" and not status.get("available"):
253
+ raise RuntimeError(str(status.get("detail") or "high-fidelity PDF engine is unavailable"))
254
+ return "precise" if status.get("available") else "legacy"
255
+
256
+
257
+ def _sha256(path: Path) -> str:
258
+ digest = hashlib.sha256()
259
+ with path.open("rb") as handle:
260
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
261
+ digest.update(chunk)
262
+ return digest.hexdigest()
263
+
264
+
265
+ def _safe_bundle_member(name: str) -> Path:
266
+ normalized = PurePosixPath(name)
267
+ windows = PureWindowsPath(name)
268
+ if (
269
+ "\\" in name or normalized.is_absolute() or windows.drive or windows.root
270
+ or not name or any(part in {"", ".", ".."} for part in name.split("/"))
271
+ ):
272
+ raise ValueError(f"unsafe engine bundle member: {name}")
273
+ for part in normalized.parts:
274
+ validate_path_component(part, "engine bundle member")
275
+ return Path(*normalized.parts)
276
+
277
+
278
+ def _download_bundle(source: str, destination: Path, progress: ProgressCallback) -> None:
279
+ parsed = urllib.parse.urlsplit(source)
280
+ if parsed.scheme in {"http", "https"}:
281
+ progress("正在下载高精度 PDF 引擎")
282
+ size = 0
283
+ with httpx.stream("GET", source, follow_redirects=True, timeout=120) as response:
284
+ try:
285
+ response.raise_for_status()
286
+ except httpx.HTTPStatusError as exc:
287
+ if response.status_code == 404:
288
+ raise RuntimeError(
289
+ "当前 CrabCode 版本尚未发布适用于本机平台与 Python 版本的高精度 PDF 资源包;"
290
+ "请发布对应离线包,或使用 --bundle 指定本地资源包"
291
+ ) from exc
292
+ raise
293
+ with destination.open("wb") as handle:
294
+ for chunk in response.iter_bytes(1024 * 1024):
295
+ size += len(chunk)
296
+ if size > MAX_ENGINE_BUNDLE_BYTES:
297
+ raise ValueError("document engine bundle exceeds 2 GiB")
298
+ handle.write(chunk)
299
+ return
300
+ path = Path(source).expanduser().resolve(strict=True)
301
+ if not path.is_file() or path.stat().st_size > MAX_ENGINE_BUNDLE_BYTES:
302
+ raise ValueError("document engine bundle is invalid")
303
+ shutil.copy2(path, destination)
304
+
305
+
306
+ def _extract_and_verify_bundle(bundle: Path, destination: Path) -> dict[str, Any]:
307
+ with zipfile.ZipFile(bundle) as archive:
308
+ try:
309
+ raw_manifest = archive.read(ENGINE_BUNDLE_MANIFEST_NAME)
310
+ manifest = json.loads(raw_manifest)
311
+ except (KeyError, json.JSONDecodeError) as exc:
312
+ raise ValueError("document engine bundle manifest is invalid") from exc
313
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("files"), list):
314
+ raise ValueError("document engine bundle manifest is invalid")
315
+ if manifest.get("schema_version") != 1:
316
+ raise ValueError("unsupported document engine bundle schema")
317
+ if manifest.get("engine_version") != BABELDOC_VERSION:
318
+ raise ValueError("document engine bundle version does not match CrabCode")
319
+ if manifest.get("python_tag") not in {None, _python_tag()}:
320
+ raise ValueError("document engine bundle targets a different Python version")
321
+ if manifest.get("platform_tag") not in {None, _platform_tag()}:
322
+ raise ValueError("document engine bundle targets a different platform")
323
+
324
+ expected: dict[str, str] = {}
325
+ for item in manifest["files"]:
326
+ if not isinstance(item, dict):
327
+ raise ValueError("document engine bundle file entry is invalid")
328
+ name = item.get("path")
329
+ digest = item.get("sha256")
330
+ if not isinstance(name, str) or not re.fullmatch(r"[0-9a-f]{64}", str(digest)):
331
+ raise ValueError("document engine bundle file entry is invalid")
332
+ member = _safe_bundle_member(name)
333
+ if member.as_posix() in expected:
334
+ raise ValueError("document engine bundle contains duplicate files")
335
+ expected[member.as_posix()] = str(digest)
336
+
337
+ extracted_bytes = 0
338
+ for name, digest in expected.items():
339
+ try:
340
+ info = archive.getinfo(name)
341
+ except KeyError as exc:
342
+ raise ValueError(f"document engine bundle is missing {name}") from exc
343
+ if info.is_dir() or info.file_size > MAX_ENGINE_BUNDLE_BYTES:
344
+ raise ValueError(f"document engine bundle entry is invalid: {name}")
345
+ extracted_bytes += info.file_size
346
+ if extracted_bytes > MAX_ENGINE_BUNDLE_BYTES:
347
+ raise ValueError("document engine bundle expands beyond 2 GiB")
348
+ target = destination / _safe_bundle_member(name)
349
+ if not target.resolve().is_relative_to(destination.resolve()):
350
+ raise ValueError(f"unsafe engine bundle target: {name}")
351
+ target.parent.mkdir(parents=True, exist_ok=True)
352
+ with archive.open(info) as source, target.open("wb") as output:
353
+ shutil.copyfileobj(source, output, length=1024 * 1024)
354
+ if _sha256(target) != digest:
355
+ raise ValueError(f"document engine bundle checksum failed: {name}")
356
+ return manifest
357
+
358
+
359
+ def _create_engine_venv(stage: Path, notify: ProgressCallback) -> Path:
360
+ notify("正在创建独立 Python 环境")
361
+ created = _run_text_command(
362
+ [sys.executable, "-m", "venv", str(stage / "venv")],
363
+ capture_output=True,
364
+ )
365
+ if created.returncode != 0:
366
+ raise RuntimeError(created.stderr.strip() or "unable to create document engine environment")
367
+ return _venv_python(stage)
368
+
369
+
370
+ def _copy_engine_worker(stage: Path) -> None:
371
+ source = Path(__file__).with_name("document_worker.py")
372
+ if not source.is_file():
373
+ raise RuntimeError("CrabCode document worker source is missing")
374
+ shutil.copy2(source, _worker_path(stage))
375
+
376
+
377
+ def _asset_entries(assets: Path) -> tuple[list[dict[str, Any]], int]:
378
+ entries: list[dict[str, Any]] = []
379
+ total = 0
380
+ for path in sorted(assets.rglob("*")):
381
+ if not path.is_file() or path.name == "asset-manifest.json":
382
+ continue
383
+ size = path.stat().st_size
384
+ total += size
385
+ entries.append({
386
+ "path": path.relative_to(assets).as_posix(),
387
+ "sha256": _sha256(path),
388
+ "size": size,
389
+ })
390
+ if not entries:
391
+ raise RuntimeError("BabelDOC did not download any runtime assets")
392
+ return entries, total
393
+
394
+
395
+ def _write_asset_manifest(assets: Path) -> int:
396
+ entries, total = _asset_entries(assets)
397
+ manifest = {
398
+ "schema_version": 1,
399
+ "engine_version": BABELDOC_VERSION,
400
+ "asset_version": BABELDOC_VERSION,
401
+ "files": entries,
402
+ }
403
+ (assets / "asset-manifest.json").write_text(
404
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
405
+ encoding="utf-8",
406
+ )
407
+ return total
408
+
409
+
410
+ def _publish_engine_stage(stage: Path, target: Path) -> None:
411
+ backup = target.with_name(f".{target.name}-backup")
412
+ if backup.exists():
413
+ shutil.rmtree(backup)
414
+ if target.exists():
415
+ replace_with_retry(target, backup)
416
+ try:
417
+ replace_with_retry(stage, target)
418
+ except Exception:
419
+ if backup.exists() and not target.exists():
420
+ replace_with_retry(backup, target)
421
+ raise
422
+ shutil.rmtree(backup, ignore_errors=True)
423
+
424
+
425
+ def _install_document_engine_from_official_source(
426
+ target: Path,
427
+ parent: Path,
428
+ notify: ProgressCallback,
429
+ ) -> None:
430
+ stage = Path(tempfile.mkdtemp(prefix=f".{target.name}-install-", dir=parent))
431
+ try:
432
+ engine_python = _create_engine_venv(stage, notify)
433
+ notify("正在从 BabelDOC 官方源安装程序与依赖")
434
+ install = _run_text_command(
435
+ [
436
+ str(engine_python),
437
+ "-m",
438
+ "pip",
439
+ "install",
440
+ "--disable-pip-version-check",
441
+ "--no-input",
442
+ f"BabelDOC=={BABELDOC_VERSION}",
443
+ ],
444
+ capture_output=True,
445
+ )
446
+ if install.returncode != 0:
447
+ raise RuntimeError(install.stderr.strip() or "BabelDOC installation failed")
448
+
449
+ assets = stage / "assets"
450
+ assets.mkdir()
451
+ notify("正在下载并校验 BabelDOC 官方模型与字体")
452
+ warmup = _run_text_command(
453
+ [
454
+ str(engine_python),
455
+ "-c",
456
+ (
457
+ "import os, sys; from pathlib import Path; "
458
+ "assets=Path(sys.argv[1]); "
459
+ "import babeldoc.const as const; "
460
+ "const.CACHE_FOLDER=assets; "
461
+ "const.TIKTOKEN_CACHE_FOLDER=assets/'tiktoken'; "
462
+ "const.TIKTOKEN_CACHE_FOLDER.mkdir(parents=True, exist_ok=True); "
463
+ "os.environ['TIKTOKEN_CACHE_DIR']=str(const.TIKTOKEN_CACHE_FOLDER); "
464
+ "from babeldoc.assets.assets import warmup; warmup()"
465
+ ),
466
+ str(assets),
467
+ ],
468
+ capture_output=True,
469
+ )
470
+ if warmup.returncode != 0:
471
+ raise RuntimeError(warmup.stderr.strip() or warmup.stdout.strip() or "BabelDOC asset download failed")
472
+
473
+ notify("正在校验高精度 PDF 引擎")
474
+ asset_bytes = _write_asset_manifest(assets)
475
+ _copy_engine_worker(stage)
476
+ manifest = {
477
+ "schema_version": 1,
478
+ "engine": "precise",
479
+ "engine_version": BABELDOC_VERSION,
480
+ "asset_version": BABELDOC_VERSION,
481
+ "protocol_version": ENGINE_PROTOCOL_VERSION,
482
+ "python_tag": _python_tag(),
483
+ "platform_tag": _platform_tag(),
484
+ "install_source": "official",
485
+ "download_bytes": asset_bytes,
486
+ }
487
+ (stage / ENGINE_MANIFEST_NAME).write_text(
488
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
489
+ encoding="utf-8",
490
+ )
491
+ notify("正在启用高精度 PDF 引擎")
492
+ _publish_engine_stage(stage, target)
493
+ finally:
494
+ if stage.exists():
495
+ shutil.rmtree(stage, ignore_errors=True)
496
+
497
+
498
+ def _install_document_engine(
499
+ bundle_source: str | None = None,
500
+ *,
501
+ progress: ProgressCallback | None = None,
502
+ ) -> dict[str, Any]:
503
+ notify = progress or (lambda _message: None)
504
+ if not ((3, 10) <= sys.version_info[:2] < (3, 14)):
505
+ raise RuntimeError("BabelDOC requires Python 3.10 through 3.13")
506
+ target = document_engine_root()
507
+ parent = target.parent
508
+ parent.mkdir(parents=True, exist_ok=True)
509
+ source = bundle_source or os.environ.get("CRABCODE_DOCUMENT_ENGINE_BUNDLE_URL", "").strip()
510
+ if not source:
511
+ _install_document_engine_from_official_source(target, parent, notify)
512
+ return {"installed": True}
513
+ with tempfile.TemporaryDirectory(prefix="crabcode-document-engine-") as temp_name:
514
+ temp = Path(temp_name)
515
+ bundle = temp / "engine.zip"
516
+ _download_bundle(source, bundle, notify)
517
+ extracted = temp / "bundle"
518
+ extracted.mkdir()
519
+ notify("正在校验高精度 PDF 引擎")
520
+ bundle_manifest = _extract_and_verify_bundle(bundle, extracted)
521
+ wheelhouse = extracted / "wheelhouse"
522
+ assets = extracted / "assets"
523
+ if not wheelhouse.is_dir() or not assets.is_dir():
524
+ raise ValueError("document engine bundle is missing wheelhouse or assets")
525
+
526
+ stage = Path(tempfile.mkdtemp(prefix=f".{target.name}-install-", dir=parent))
527
+ try:
528
+ engine_python = _create_engine_venv(stage, notify)
529
+ notify("正在安装高精度 PDF 引擎")
530
+ install = _run_text_command(
531
+ [
532
+ str(engine_python),
533
+ "-m",
534
+ "pip",
535
+ "install",
536
+ "--no-index",
537
+ "--find-links",
538
+ str(wheelhouse),
539
+ f"BabelDOC=={BABELDOC_VERSION}",
540
+ ],
541
+ capture_output=True,
542
+ )
543
+ if install.returncode != 0:
544
+ raise RuntimeError(install.stderr.strip() or "offline engine installation failed")
545
+ install_crabcode = _run_text_command(
546
+ [
547
+ str(engine_python),
548
+ "-m",
549
+ "pip",
550
+ "install",
551
+ "--no-index",
552
+ "--no-deps",
553
+ "--find-links",
554
+ str(wheelhouse),
555
+ f"crabcode=={CRABCODE_VERSION}",
556
+ ],
557
+ capture_output=True,
558
+ )
559
+ if install_crabcode.returncode != 0:
560
+ raise RuntimeError(install_crabcode.stderr.strip() or "offline CrabCode worker installation failed")
561
+ shutil.copytree(assets, stage / "assets")
562
+ _copy_engine_worker(stage)
563
+ _json = {
564
+ "schema_version": 1,
565
+ "engine": "precise",
566
+ "engine_version": BABELDOC_VERSION,
567
+ "asset_version": bundle_manifest.get("asset_version") or BABELDOC_VERSION,
568
+ "protocol_version": ENGINE_PROTOCOL_VERSION,
569
+ "python_tag": _python_tag(),
570
+ "platform_tag": _platform_tag(),
571
+ "bundle_sha256": _sha256(bundle),
572
+ "bundle_bytes": bundle.stat().st_size,
573
+ "install_source": "offline_bundle",
574
+ }
575
+ (stage / ENGINE_MANIFEST_NAME).write_text(
576
+ json.dumps(_json, ensure_ascii=False, indent=2) + "\n",
577
+ encoding="utf-8",
578
+ )
579
+ notify("正在启用高精度 PDF 引擎")
580
+ _publish_engine_stage(stage, target)
581
+ finally:
582
+ if stage.exists():
583
+ shutil.rmtree(stage, ignore_errors=True)
584
+ return {"installed": True}
585
+
586
+
587
+ def install_document_engine(
588
+ bundle_source: str | None = None,
589
+ *,
590
+ progress: ProgressCallback | None = None,
591
+ ) -> dict[str, Any]:
592
+ target = document_engine_root()
593
+ target.parent.mkdir(parents=True, exist_ok=True)
594
+ state_path = target.parent / ".install-state.json"
595
+ notify = progress or (lambda _message: None)
596
+
597
+ def tracked(message: str) -> None:
598
+ status = (
599
+ "downloading"
600
+ if "下载" in message
601
+ else "verifying"
602
+ if "校验" in message
603
+ else "installing"
604
+ )
605
+ state_path.write_text(
606
+ json.dumps({"status": status, "detail": message}, ensure_ascii=False) + "\n",
607
+ encoding="utf-8",
608
+ )
609
+ notify(message)
610
+
611
+ tracked("正在下载高精度 PDF 引擎")
612
+ try:
613
+ _install_document_engine(bundle_source, progress=tracked)
614
+ except Exception as exc:
615
+ state_path.unlink(missing_ok=True)
616
+ current = document_engine_status()
617
+ if current["status"] != "ready":
618
+ state_path.write_text(
619
+ json.dumps({"status": "broken", "detail": str(exc)}, ensure_ascii=False) + "\n",
620
+ encoding="utf-8",
621
+ )
622
+ raise
623
+ state_path.unlink(missing_ok=True)
624
+ return document_engine_status()
625
+
626
+
627
+ def remove_document_engine() -> dict[str, Any]:
628
+ target = document_engine_root()
629
+ expected_parent = target.parent
630
+ if target.name != BABELDOC_VERSION or expected_parent.name != "babeldoc":
631
+ raise RuntimeError("refusing to remove an unexpected document engine path")
632
+ if target.is_symlink():
633
+ target.unlink()
634
+ elif target.exists():
635
+ shutil.rmtree(target)
636
+ (target.parent / ".install-state.json").unlink(missing_ok=True)
637
+ return document_engine_status()