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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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()
|