fix: download optional Python runtime resources explicitly
This commit is contained in:
1 parent
15c10e0e87
commit
89f35fe2e9
34 files changed
+1159
-259
No files matched your search
+8
-8
@@ -18,14 +18,14 @@
|
||||
en: 5fdde26e9e8c6108
|
||||
zh: 629559d733c1f0c5
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/plugin-resolution-the-vfs-holds-a-real-package-tree-the-closure-manifest-is-the-deploy-root:
|
||||
en: c35ea4b858448b49
|
||||
zh: 1f8ff0a9f82ca881
|
||||
en: 985e0eb63f3d1fd6
|
||||
zh: 337cc44fadc6227e
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts:
|
||||
en: d3bbd24ead02af4f
|
||||
zh: 64623d88bee4314b
|
||||
en: 921085cb9d894688
|
||||
zh: 12d99fe153d36738
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development:
|
||||
en: 9bd60549d4a52ec0
|
||||
zh: e2343b7210212ee0
|
||||
en: 02e6b36f9f92c83a
|
||||
zh: edd8b2db7a787483
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage:
|
||||
en: 31a5aecb8bf828bb
|
||||
zh: 2dffb6fd2e0f8194
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 0426f598f3f44e64
|
||||
zh: fc66728e17f3bcfb
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/testing:
|
||||
en: b3c9820f4dc7c6c8
|
||||
zh: 3b5768ef1770bfcb
|
||||
en: 8e7f46bbb595afa9
|
||||
zh: 4641894ed285d717
|
||||
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/alternatives-considered:
|
||||
en: 50d965ca81a401ba
|
||||
zh: 0c6296245b97b9ac
|
||||
|
||||
+5
-5
@@ -44,19 +44,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru
|
||||
|
||||
The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
|
||||
|
||||
The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `<executable-stem>-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection.
|
||||
The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in the explicitly downloaded sidecar’s `node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. The build locks the selected engine and dependency locations to exact npm tarballs; downloads verify their integrity before publication.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`.
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, lightweight download metadata and Office skills, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published.
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, lightweight resource metadata and skills, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published.
|
||||
|
||||
The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback.
|
||||
|
||||
@@ -74,7 +74,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio
|
||||
|
||||
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
|
||||
|
||||
The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive.
|
||||
The installed-wheel `sdk-office` scenario relocates the lightweight wheel payload and explicitly downloads the complete npm Office sidecar with its independent Node. It requires exactly one target backend, verifies that the SDK loads the kit from the downloaded sidecar, and converts DOCX through the skill CLI with an empty PATH. Python Office operations use the same CLI. Explicit downloads reuse the existing build preparation implementation; SDK startup performs no downloads. The [runtime reference](../../../../python/sdk-runtime/README.md) owns cache and activation behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+5
-5
@@ -44,19 +44,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
|
||||
|
||||
[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `<executable-stem>-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。
|
||||
[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于显式下载的 sidecar 的 `node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。构建将所选引擎和依赖位置锁定到确切的 npm 归档;下载会在发布缓存前校验完整性。
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、轻量资源元数据与 skills 及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。
|
||||
|
||||
Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。
|
||||
|
||||
@@ -74,7 +74,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。
|
||||
安装后 wheel 的 `sdk-office` 场景迁移轻量 wheel 载荷,并显式下载完整 npm Office sidecar 及其独立 Node。它要求仅有一个目标后端,验证 SDK 从下载的 sidecar 加载 kit,并通过 skill CLI 在空 PATH 下转换 DOCX。Python Office 操作使用同一 CLI。显式下载复用现有构建准备实现;SDK 启动不执行下载。[运行时参考](../../../../python/sdk-runtime/README.zh.md)拥有缓存和启用行为的说明。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md
|
||||
/:
|
||||
en: d6baeff3354ba733
|
||||
zh: 54e736a9718228f8
|
||||
/download-optional-python-runtime-resources-explicitly:
|
||||
en: b9a46f870d69cf79
|
||||
zh: 1ed1da9167fad3b1
|
||||
/download-optional-python-runtime-resources-explicitly/change:
|
||||
en: 92d45d6f2a499e9b
|
||||
zh: d4560da85fc5cfbf
|
||||
/download-optional-python-runtime-resources-explicitly/migration:
|
||||
en: 0f0b15be52301aed
|
||||
zh: 646d7b47069714d4
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Python runtime wheels require explicit downloads for Office conversion and bundled authoring environments."
|
||||
---
|
||||
|
||||
# Download optional Python runtime resources explicitly
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Python runtime wheels omit the Office sidecar and authoring interpreters and libraries. SDK startup does not download them. Office operations require an explicit download; the authoring environment and conversion engine can be selected independently. The SDK still packages the normal `dsh` CLI and requires no system Node.js.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Before processing Office files, call `deepseek_harness_runtime.download_office()`. Before using the bundled authoring environment, call `deepseek_harness_runtime.download_primary_runtime()`. Both reuse completed caches and return absolute paths.
|
||||
2. For offline deployment, prepare the cache with the matching runtime release on the target platform and copy it to that platform's deployment. Set `DSH_RESOURCE_CACHE` to its absolute root before downloads and SDK startup. See the [runtime reference](../../../../python/sdk-runtime/README.md).
|
||||
3. Start the SDK and confirm the required resources are available. `deepseek_harness_runtime.resolve_office_launch_args()` checks the Office CLI paths without downloading. Existing `DSH_PRIMARY_RUNTIME` paths remain supported; an empty value disables the authoring query. Profile patches can independently disable `skill-office`.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Python 运行时 wheel 的 Office 转换与内置创作环境需要显式下载。"
|
||||
---
|
||||
|
||||
# 显式下载可选 Python 运行时资源
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
Python 运行时 wheel 不包含 Office sidecar、创作解释器和库。SDK 启动不会下载这些资源。Office 操作需要显式下载;创作环境和转换引擎可以独立选择。SDK 仍打包常规 `dsh` CLI,无需系统 Node.js。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 处理 Office 文件前调用 `deepseek_harness_runtime.download_office()`。使用内置创作环境前调用 `deepseek_harness_runtime.download_primary_runtime()`。两者复用完整缓存并返回绝对路径。
|
||||
2. 离线部署时,在目标平台使用匹配的运行时发布版本准备缓存,并将其复制到该平台的部署环境。下载和启动 SDK 前,将 `DSH_RESOURCE_CACHE` 设为缓存的绝对根目录。详见[运行时参考](../../../../python/sdk-runtime/README.zh.md)。
|
||||
3. 启动 SDK 并确认所需资源可用。`deepseek_harness_runtime.resolve_office_launch_args()` 检查 Office CLI 路径,不执行下载。既有 `DSH_PRIMARY_RUNTIME` 路径仍受支持;空值禁用创作环境查询。Profile patch 可独立禁用 `skill-office`。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 4e53164159b5ce41
|
||||
zh: 89582dc0294d0776
|
||||
/deepseek-ai-dsh-sdk-app/use-this-package:
|
||||
en: 465be8e510a86913
|
||||
zh: 07e2e1efba1d108b
|
||||
en: 63573087d1db9aff
|
||||
zh: 638612ac3f0da2c0
|
||||
/deepseek-ai-dsh-sdk-app/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
|
||||
@@ -33,7 +33,7 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo
|
||||
|
||||
The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection.
|
||||
|
||||
The packaged Python runtime enables the [Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package) by default. `DSH_PRIMARY_RUNTIME` overrides its bundled payload; an empty value disables both rows. Source launches without a carrier default remain opt-in. The tool reads the payload in place. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence over bundled skills. See the [runtime package](../../../python/sdk-runtime/README.md) for packaging and configuration.
|
||||
The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-bundle"
|
||||
|
||||
SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。
|
||||
|
||||
打包的 Python runtime 默认启用 [Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。`DSH_PRIMARY_RUNTIME` 覆盖随包 payload;空值禁用两行。没有载体默认路径的源码启动仍需显式启用。工具原位读取 payload。profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先于随包 skills。打包与配置见 [runtime 包](../../../python/sdk-runtime/README.zh.md)。
|
||||
Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
- id: skill-office
|
||||
name: '@deepseek-ai/dsh-skill-office'
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)"
|
||||
disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
config:
|
||||
assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')"
|
||||
node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
|
||||
assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')"
|
||||
cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI"
|
||||
node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')"
|
||||
@@ -6,14 +6,14 @@
|
||||
en: 1ea1d8e6d2c14b02
|
||||
zh: 8e0b9903158667a1
|
||||
/deepseek-harness-runtime-bin/installed-commands-and-artifacts:
|
||||
en: af4e7919327e1991
|
||||
zh: b4114823b4f1a3fa
|
||||
en: fb7f6c3cec95833b
|
||||
zh: b04e60cbc0ba96fc
|
||||
/deepseek-harness-runtime-bin/python-module-api:
|
||||
en: a88b7574e0e00a28
|
||||
zh: 3fd4fe8c0b338e79
|
||||
en: 78a200e328d96637
|
||||
zh: f3b7a0b69b7a39e0
|
||||
/deepseek-harness-runtime-bin/packaged-profile-resolution:
|
||||
en: 641c647efc49c568
|
||||
zh: a29371de9fbbebfd
|
||||
en: 30b9d8f69da71b23
|
||||
zh: 9eb984af84dc7ead
|
||||
/deepseek-harness-runtime-bin/build-and-distribution:
|
||||
en: 437ab8ba3bbe7ed6
|
||||
zh: 3f321a73e472220b
|
||||
en: ce9b1a8e4a7f0a18
|
||||
zh: 43d09e469344cd87
|
||||
@@ -10,11 +10,22 @@ The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Py
|
||||
|
||||
Production executables are named `deepseek-harness-sdk-runtime-<platform>-<arch>` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published.
|
||||
|
||||
Each target also requires `<executable-stem>-office/`, where the stem excludes `.exe`. This directory contains the complete installed Office packages and their dependencies, preserving engine resources, manifests, licenses, source inventories, and helper permissions. Copy this directory together with the executable. A missing target engine fails the sidecar build with its npm package name and target platform/architecture.
|
||||
The wheel carries only the executable, native execution helpers, and `<platform>-<arch>/` with `downloads.json` and the lightweight `office-skills/` tree. CPython, authoring libraries, standalone Node, pnpm, and the complete Office sidecar are optional downloads. Importing Python modules, starting SDK profiles, and processing Office files never download resources.
|
||||
|
||||
Each wheel also includes `<platform>-<arch>/primary-runtime/` (CPython, locked Office Python libraries, standalone Node, and pnpm) and sibling `office-skills/` (three default workflows and their shared checker). These are ordinary relocatable files, not bytes embedded in the executable. The shared builder selects target-native archives for all five wheel targets and executes its smoke on the native build host. Packaging and installed-runtime lookup reject missing resources, wrong-platform metadata, and lost Python or Node executable permissions. The short platform directory avoids repeating the executable name in Python DLL paths on Windows.
|
||||
Download either resource explicitly before using it:
|
||||
|
||||
The packaged bootstrap supplies `DSH_BUNDLED_PRIMARY_RUNTIME` as a carrier default. The `sdk` profile uses it when `DSH_PRIMARY_RUNTIME` is unset; an explicit path overrides it, and an empty string disables the query and Office provider. External payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. Loaded skills expose the bundled Node and adjacent Office CLI as absolute paths; custom Python-only payloads must patch `skill-office.config.cli: false` or supply `skill-office.config.node`. The SDK reads Python in place without copying it into `DSH_HOME`. Source and dev-only Node carriers have no bundled default; they use an explicit `DSH_PRIMARY_RUNTIME`.
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office, download_primary_runtime
|
||||
|
||||
office = download_office()
|
||||
primary_runtime = download_primary_runtime()
|
||||
```
|
||||
|
||||
`download_office()` downloads the locked npm Kit, target engine, and complete dependency tree, plus standalone Node for the Kit CLI. `download_primary_runtime()` independently downloads CPython, locked Python libraries, Node, pnpm, and Office skills. Both return absolute resource directories and reuse completed caches. The existing local `installPrimaryRuntime()` operation still copies an already available payload; it does not download.
|
||||
|
||||
`DSH_RESOURCE_CACHE` selects an absolute cache root, defaulting to `~/.cache/deepseek-harness/resources`. Resource identities include the release's locked inputs. Downloads verify archive hashes and package identities, preserve executable permissions, and publish completed directories atomically. Failed downloads can be retried. `DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` bounds each archive download, including its body; the default is `300000`, and accepted values are integers from `1` to `2147483647`. Downloads follow the Harness proxy environment policy. Installed wheel files are never modified.
|
||||
|
||||
On subsequent launches the packaged bootstrap exposes downloaded resources to the SDK profile. `DSH_PRIMARY_RUNTIME` overrides the cached authoring environment; an empty value disables its query. `DSH_OFFICE_SIDECAR` can select an absolute sidecar directory with its standalone Node. Office skills are available when either resource is selected; authoring-only deployments expose the skills with the Kit CLI disabled. External authoring payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. The SDK reads resources in place without copying them into `DSH_HOME`. The `sdk-minimal` profile does not mount these providers.
|
||||
|
||||
Skill selection is independent of delivery: project, custom-directory, and user filesystem skills override same-name bundled skills. An SDK patch can disable only the Office provider while retaining the Python query:
|
||||
|
||||
@@ -25,7 +36,7 @@ Skill selection is independent of delivery: project, custom-directory, and user
|
||||
|
||||
To replace the three Office workflows and shared checker as a set, patch `skill-office.config.assetRoot` to another absolute resource directory. Use the filesystem skill provider for arbitrary skill collections. Configuration patches apply at process startup; changing skills does not require rebuilding the runtime wheel or Python environment.
|
||||
|
||||
Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists.
|
||||
Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists.
|
||||
|
||||
Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`.
|
||||
|
||||
@@ -34,6 +45,9 @@ Both carriers execute the same `dsh` grammar and shipped profiles, including the
|
||||
- `bundled_package_dir() -> Path` returns the installed module-data root and verifies its release metadata.
|
||||
- `bundled_runtime_path() -> Path` returns the current platform executable and verifies required sidecars.
|
||||
- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` returns the executable argv by default. Explicit `mode="node"` or `DSH_RUNTIME_MODE=node` selects the repo-only Node carrier.
|
||||
- `download_office() -> Path` explicitly downloads or reuses the Office sidecar and its standalone Node.
|
||||
- `download_primary_runtime() -> Path` explicitly downloads or reuses the authoring environment.
|
||||
- `resolve_office_launch_args() -> tuple[str, str]` locates the installed Node and Kit CLI without downloading; missing resources raise `FileNotFoundError` with the download instruction.
|
||||
- `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME`. On Windows it waits for the bundled process with inherited standard streams and forwards its exit status; on POSIX it replaces the Python process.
|
||||
|
||||
Unsupported platforms and missing executables or sidecars raise `FileNotFoundError` with the build and installation routes. Unknown runtime modes raise `ValueError`.
|
||||
@@ -42,7 +56,7 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr
|
||||
|
||||
`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Runtime resolution uses an in-memory generation instead of disk symlinks or proxy packages. Fallback imports use recorded declaring-package paths, including paths inside the executable's virtual filesystem, so built-in rows and external plugin peers share the bundled Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars.
|
||||
|
||||
The Python bootstrap resolves the Office kit from its adjacent directory so native helpers and URL Workers use real filesystem paths. The kit owns engine selection and validation; the Python bootstrap adds no runtime download or compilation. The Office smoke also retrieves CLI paths from the loaded skill and executes capabilities and DOCX conversion with an empty PATH.
|
||||
The Python bootstrap resolves the Office kit from its explicitly downloaded or selected sidecar so native helpers and URL Workers use real filesystem paths. The same resource preparation implementation serves repository builds and explicit Python downloads. The kit owns document operations; the Office smoke retrieves CLI paths from the loaded skill and executes capabilities and DOCX conversion with an empty PATH.
|
||||
|
||||
External profile management uses `dsh plugin --profile <name> ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not.
|
||||
|
||||
@@ -50,6 +64,6 @@ External profile management uses `dsh plugin --profile <name> ...`. That command
|
||||
|
||||
Production deployment permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches.
|
||||
|
||||
From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. Deployment disables workspace hoisting so unrelated source packages stay out of the payload. Staging shares root workspace packages with nested consumers of the same pnpm dependency identity, preserving singleton services while retaining different peer resolutions. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version.
|
||||
From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. Only the target platform’s PTY prebuilds are packaged. Package README, CHANGELOG, and HISTORY Markdown outside runtime assets is omitted. Deployment disables workspace hoisting so unrelated source packages stay out of the payload. Staging shares root workspace packages with nested consumers of the same pnpm dependency identity, preserving singleton services while retaining different peer resolutions. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version.
|
||||
|
||||
The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md).
|
||||
The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the lightweight target payload, explicitly downloads the sidecar, and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md).
|
||||
@@ -10,11 +10,22 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python
|
||||
|
||||
生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime-<platform>-<arch>`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。wheel 包标签必须与载荷严格匹配;不发布 Windows arm64 wheel 包。
|
||||
|
||||
每个目标还要求 `<executable-stem>-office/`,其中 stem 不含 `.exe`。该目录包含完整的已安装 Office 包及其依赖,保留引擎资源、清单、许可证、源码清单与辅助程序权限。复制可执行文件时必须一并复制此目录。缺少目标引擎会使 sidecar 构建失败,错误会指出其 npm 包名与目标平台/架构。
|
||||
wheel 仅携带可执行程序、原生执行辅助程序,以及包含 `downloads.json` 和轻量 `office-skills/` 目录的 `<platform>-<arch>/`。CPython、创作库、独立 Node、pnpm 和完整 Office sidecar 均为可选下载资源。导入 Python 模块、启动 SDK profile 和处理 Office 文件都不会下载资源。
|
||||
|
||||
每个 wheel 还包含 `<platform>-<arch>/primary-runtime/`(CPython、锁定版本的 Office Python 库、独立 Node 和 pnpm)及同级 `office-skills/`(三个默认工作流与共用检查脚本)。它们是可重定位的普通文件,不嵌入可执行文件。共享构建器为全部五个 wheel 目标选择原生归档,并在对应构建主机上执行冒烟检查。打包与已安装运行时定位会拒绝缺失资源、平台不符的元数据及 Python 或 Node 执行权限丢失。 较短的平台目录避免在 Windows Python DLL 路径中重复可执行文件名称。
|
||||
使用前显式下载所需的任一种资源:
|
||||
|
||||
打包启动器通过 `DSH_BUNDLED_PRIMARY_RUNTIME` 提供载体默认路径。`sdk` profile 在未设置 `DSH_PRIMARY_RUNTIME` 时使用该路径;显式路径覆盖它,空字符串禁用查询与 Office 提供方。外部 payload 保持 `primary-runtime/` 与同级 `office-skills/` 布局。加载后的技能给出内置 Node 与伴随 Office CLI 的绝对路径;自定义的纯 Python payload 必须配置 `skill-office.config.cli: false` 或提供 `skill-office.config.node`。SDK 原位读取 Python,不复制到 `DSH_HOME`。源码与仅供开发的 Node 载体没有随包默认路径,需显式设置 `DSH_PRIMARY_RUNTIME`。
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office, download_primary_runtime
|
||||
|
||||
office = download_office()
|
||||
primary_runtime = download_primary_runtime()
|
||||
```
|
||||
|
||||
`download_office()` 下载锁定版本的 npm Kit、目标引擎及完整依赖树,并准备运行 Kit CLI 的独立 Node。`download_primary_runtime()` 独立下载 CPython、锁定的 Python 库、Node、pnpm 和 Office skills。两者返回绝对资源目录,并复用完整缓存。现有本地 `installPrimaryRuntime()` 操作仍只复制已有载荷,不执行下载。
|
||||
|
||||
`DSH_RESOURCE_CACHE` 指定绝对缓存根目录,默认为 `~/.cache/deepseek-harness/resources`。资源身份包含发布版本锁定的输入。下载会校验归档哈希和包身份、保留执行权限,并原子发布完整目录。下载失败后可重试。`DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` 限制每个归档的下载耗时,包含响应内容;默认值为 `300000`,接受 `1` 到 `2147483647` 的整数。下载遵循 Harness 代理环境策略。已安装的 wheel 文件不会被修改。
|
||||
|
||||
后续启动时,打包 bootstrap 将已下载资源提供给 SDK profile。`DSH_PRIMARY_RUNTIME` 覆盖缓存中的创作环境;空值禁用其查询。`DSH_OFFICE_SIDECAR` 可选择包含独立 Node 的绝对 sidecar 目录。选择任一种资源即可使用 Office skills;仅有创作环境时,skills 中的 Kit CLI 被禁用。外部创作载荷保留 `primary-runtime/` 及同级 `office-skills/` 布局。SDK 原位读取资源,不复制到 `DSH_HOME`。`sdk-minimal` profile 不挂载这些提供方。
|
||||
|
||||
选择 skills 与交付资源相互独立:项目、自定义目录和用户文件系统 skills 优先于同名随包 skills。SDK patch 可以仅禁用 Office 提供方,同时保留 Python 查询:
|
||||
|
||||
@@ -25,7 +36,7 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python
|
||||
|
||||
要成套替换三个 Office 工作流与共用检查脚本,可将 `skill-office.config.assetRoot` 配置为另一个绝对资源目录。任意 skill 集合通过文件系统 skill 提供方加载。配置 patch 在进程启动时生效;切换 skills 无需重建 runtime wheel 或 Python 环境。
|
||||
|
||||
仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。
|
||||
仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/runtime-bootstrap.mjs`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。
|
||||
|
||||
两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest(元数据清单)定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。
|
||||
|
||||
@@ -34,6 +45,9 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python
|
||||
- `bundled_package_dir() -> Path` 返回已安装模块数据根目录,并校验发布元数据。
|
||||
- `bundled_runtime_path() -> Path` 返回当前平台可执行程序,并校验必需伴随文件。
|
||||
- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` 默认返回可执行程序 argv。显式 `mode="node"` 或 `DSH_RUNTIME_MODE=node` 会选择仅限仓库使用的 Node 载体。
|
||||
- `download_office() -> Path` 显式下载或复用 Office sidecar 及其独立 Node。
|
||||
- `download_primary_runtime() -> Path` 显式下载或复用创作环境。
|
||||
- `resolve_office_launch_args() -> tuple[str, str]` 定位已安装的 Node 和 Kit CLI,不执行下载;资源缺失时抛出含下载指令的 `FileNotFoundError`。
|
||||
- `main()` 实现已安装的 `dsh` 控制台命令,并拒绝缺失或空白的 `DSH_HOME`。在 Windows 上,它让打包进程继承标准流,等待其结束并转发退出状态;在 POSIX 上,它替换 Python 进程。
|
||||
|
||||
不支持的平台以及缺失的可执行程序或伴随文件会抛出 `FileNotFoundError`,并指出构建与安装路径。未知运行时模式会抛出 `ValueError`。
|
||||
@@ -42,7 +56,7 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python
|
||||
|
||||
`dsh` 在显式指定的主目录下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。运行时解析使用内存中的 generation,不创建磁盘符号链接或代理包。fallback 导入使用记录的声明包路径,包括可执行程序虚拟文件系统内的路径,因此内置配置项与外部插件 peer 共享内置的 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。
|
||||
|
||||
Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。kit 负责引擎选择与校验;Python bootstrap 不增加运行时下载或编译。Office smoke 还从已加载技能获取 CLI 路径,并在空 PATH 下执行 capabilities 和 DOCX 转换。
|
||||
Python bootstrap 从显式下载或选择的 sidecar 解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。仓库构建与显式 Python 下载共用资源准备实现。kit 负责文档操作;Office smoke 从已加载技能获取 CLI 路径,并在空 PATH 下执行 capabilities 和 DOCX 转换。
|
||||
|
||||
外部 profile 管理使用 `dsh plugin --profile <name> ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。
|
||||
|
||||
@@ -50,6 +64,6 @@ Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL
|
||||
|
||||
生产部署允许工作区中不属于运行时闭包的补丁保持未使用;闭包内包的补丁仍必须成功应用。此例外仅用于部署命令,仓库安装仍拒绝未使用的补丁。
|
||||
|
||||
在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。部署会关闭工作区提升,避免无关源码包进入载荷。暂存步骤让使用相同 pnpm 依赖标识的嵌套消费者共享根目录的工作区包,保留服务单例,同时保留不同的对等依赖解析。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。
|
||||
在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。打包时只保留目标平台的 PTY 预构建文件,并排除运行时资源目录之外的包内 README、CHANGELOG 和 HISTORY Markdown 文档。部署会关闭工作区提升,避免无关源码包进入载荷。暂存步骤让使用相同 pnpm 依赖标识的嵌套消费者共享根目录的工作区包,保留服务单例,同时保留不同的对等依赖解析。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。
|
||||
|
||||
已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。
|
||||
已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移轻量目标载荷、显式下载 sidecar,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。
|
||||
@@ -86,8 +86,6 @@ class RuntimeBuildHook(BuildHookInterface):
|
||||
)
|
||||
if "-macos-" in expected_executable:
|
||||
expected_files.append(f"{expected_executable}-spawn-helper")
|
||||
office = runtime_dir / f"{expected_executable.removesuffix('.exe')}-office"
|
||||
expected_files.append(office.name)
|
||||
resources = runtime_dir / target
|
||||
expected_files.append(resources.name)
|
||||
expected_files.sort()
|
||||
@@ -98,20 +96,9 @@ class RuntimeBuildHook(BuildHookInterface):
|
||||
)
|
||||
for executable in runtime_files:
|
||||
if executable == resources:
|
||||
validate = runpy.run_path(str(runtime_dir.parent / "_resources.py"))["validate_resources"]
|
||||
validate = runpy.run_path(str(runtime_dir.parent / "_resources.py"))["validate_downloads"]
|
||||
validate(resources, target)
|
||||
continue
|
||||
if executable == office:
|
||||
adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json"
|
||||
if not adapter.is_file():
|
||||
raise RuntimeError(f"runtime Office dependency is missing: {adapter}")
|
||||
native = target.replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {})
|
||||
engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm"
|
||||
required = office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"
|
||||
if not required.is_file():
|
||||
raise RuntimeError(f"runtime Office dependency is missing: {required}")
|
||||
continue
|
||||
if not executable.is_file():
|
||||
raise RuntimeError(f"runtime executable is not a file: {executable}")
|
||||
if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0:
|
||||
|
||||
@@ -1,20 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/** Private entry owned by the Python single-file runtime packaging. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { registerHooks } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { isSea } from 'node:sea'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
if (isSea()) {
|
||||
// Office spawns executable helpers and URL workers; its complete package tree must be real files.
|
||||
const parentURL = pathToFileURL(`${process.execPath.replace(/\.exe$/i, '')}-office/package.json`).href
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform
|
||||
const resourceDirectory = join(isSea() ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url))), `${platform}-${process.arch}`)
|
||||
let downloads
|
||||
let cache
|
||||
if (existsSync(join(resourceDirectory, 'downloads.json'))) {
|
||||
downloads = JSON.parse(await readFile(join(resourceDirectory, 'downloads.json'), 'utf8'))
|
||||
const root = process.env.DSH_RESOURCE_CACHE ?? join(homedir(), '.cache', 'deepseek-harness', 'resources')
|
||||
if (!isAbsolute(root)) throw new Error('DSH_RESOURCE_CACHE must be an absolute directory')
|
||||
if (!/^[a-f0-9]{64}$/u.test(downloads.identity)) throw new Error('runtime download manifest has an invalid cache identity')
|
||||
cache = join(root, downloads.identity)
|
||||
}
|
||||
|
||||
/** Publish a complete immutable resource directory; overlapping downloads may share the winner. */
|
||||
async function downloadResource(kind) {
|
||||
if (downloads === undefined) throw new Error('runtime download manifest is missing; rebuild or reinstall the runtime wheel')
|
||||
const destination = join(cache, kind)
|
||||
if (!existsSync(join(destination, 'complete'))) {
|
||||
await mkdir(cache, { recursive: true })
|
||||
const staging = await mkdtemp(join(cache, `.${kind}-`))
|
||||
let disposeProxy
|
||||
try {
|
||||
const { installProxyFromEnvironment } = await import('@deepseek-ai/dsh-http-proxy')
|
||||
disposeProxy = await installProxyFromEnvironment({ get(name) {
|
||||
const value = process.env[name]
|
||||
return value === undefined ? undefined : { value }
|
||||
} }, message => console.error(message))
|
||||
const { downloadNodeRuntime, preparePrimaryRuntime } = await import('./primary-runtime.mjs')
|
||||
const archives = join(cache, 'archives')
|
||||
if (kind === 'office') {
|
||||
const { downloadOfficeSidecar } = await import('./office-sidecar.mjs')
|
||||
await downloadNodeRuntime(downloads.target, join(staging, 'node'), archives)
|
||||
await downloadOfficeSidecar(downloads.office, staging, archives)
|
||||
} else {
|
||||
await preparePrimaryRuntime({ target: downloads.target, output: staging, cache: archives,
|
||||
version: downloads.version, pnpmArchive: downloads.pnpm, skillSource: join(resourceDirectory, 'office-skills') })
|
||||
}
|
||||
await writeFile(join(staging, 'complete'), downloads.identity)
|
||||
try { await rename(staging, destination) } catch (error) {
|
||||
if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)
|
||||
|| await readFile(join(destination, 'complete'), 'utf8') !== downloads.identity) throw error
|
||||
}
|
||||
} finally {
|
||||
try { await disposeProxy?.() } finally { await rm(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
}
|
||||
if (await readFile(join(destination, 'complete'), 'utf8') !== downloads.identity) throw new Error(`runtime cache identity mismatch: ${destination}`)
|
||||
return kind === 'primary' ? join(destination, 'primary-runtime') : destination
|
||||
}
|
||||
|
||||
const office = process.env.DSH_OFFICE_SIDECAR ?? (cache !== undefined && existsSync(join(cache, 'office', 'complete')) ? join(cache, 'office') : undefined)
|
||||
if (office !== undefined) {
|
||||
if (!isAbsolute(office)) throw new Error('DSH_OFFICE_SIDECAR must be an absolute directory')
|
||||
const parentURL = pathToFileURL(join(office, 'package.json')).href
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const office = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json'
|
||||
return nextResolve(specifier, office ? { ...context, parentURL } : context)
|
||||
const kit = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json'
|
||||
return nextResolve(specifier, kit ? { ...context, parentURL } : context)
|
||||
},
|
||||
})
|
||||
process.env.DSH_BUNDLED_OFFICE_NODE = join(office, 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
process.env.DSH_BUNDLED_OFFICE_CLI = join(office, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib', 'cli.js')
|
||||
} else if (isSea()) {
|
||||
process.env.DSH_BUNDLED_OFFICE_CLI = ''
|
||||
}
|
||||
if (downloads !== undefined) process.env.DSH_BUNDLED_OFFICE_SKILLS = join(resourceDirectory, 'office-skills')
|
||||
|
||||
const selectorName = 'DSH_SUBPROCESS_RUNNER'
|
||||
const selection = process.env[selectorName]
|
||||
@@ -22,17 +80,18 @@ const aclRunner = process.platform === 'win32'
|
||||
? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner'))
|
||||
: undefined
|
||||
|
||||
if (aclRunner !== undefined && process.argv[2] === aclRunner) {
|
||||
if (process.argv[2] === '--download-office' || process.argv[2] === '--download-primary-runtime') {
|
||||
const path = await downloadResource(process.argv[2] === '--download-office' ? 'office' : 'primary')
|
||||
console.log(JSON.stringify(path))
|
||||
} else if (aclRunner !== undefined && process.argv[2] === aclRunner) {
|
||||
process.argv.splice(1, 1)
|
||||
await import('@deepseek-ai/dsh-sandbox-windows-acl/runner')
|
||||
} else if (process.env.DSH_PTC_RUNTIME_NODE === '1') {
|
||||
Reflect.deleteProperty(process.env, 'DSH_PTC_RUNTIME_NODE')
|
||||
await import('@deepseek-ai/dsh-ptc-runtime-node/process')
|
||||
} else if (selection === undefined) {
|
||||
if (isSea()) {
|
||||
// Carrier default stays separate so process/home environment and profile patches can override it.
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform
|
||||
process.env.DSH_BUNDLED_PRIMARY_RUNTIME = join(dirname(process.execPath), `${platform}-${process.arch}`, 'primary-runtime')
|
||||
if (cache !== undefined && existsSync(join(cache, 'primary', 'complete'))) {
|
||||
process.env.DSH_BUNDLED_PRIMARY_RUNTIME = join(cache, 'primary', 'primary-runtime')
|
||||
}
|
||||
const { runCli } = await import('@deepseek-ai/dsh/lib/bin.js')
|
||||
await runCli()
|
||||
|
||||
@@ -10,7 +10,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's
|
||||
Node installation.
|
||||
- **node (dev-only)**: the full deploy closure under ``runtime/node/``
|
||||
(``package.json`` + ``node_modules/``), executed as ``node
|
||||
runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a
|
||||
runtime/node/runtime-bootstrap.mjs`` on a
|
||||
system Node >= 22.19. It is the current checkout's source build, never
|
||||
selected automatically, and excluded from wheel/sdist distributions.
|
||||
|
||||
@@ -28,7 +28,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from ._resources import validate_resources
|
||||
from ._resources import validate_downloads, office_launch_args
|
||||
|
||||
PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json"
|
||||
|
||||
@@ -59,10 +59,9 @@ def bundled_runtime_path() -> Path:
|
||||
"""Absolute path of the bundled single-file runtime executable for the current platform.
|
||||
|
||||
Raises FileNotFoundError when the platform is unsupported, the executable
|
||||
has not been placed into this package, the ripgrep or Office sidecar is
|
||||
missing, the authoring resources are incomplete, or the required macOS
|
||||
spawn helper is missing. Invalid authoring metadata or Python permissions
|
||||
raise ValueError. Missing executable/Office messages name
|
||||
has not been placed into this package, the ripgrep sidecar or download
|
||||
manifest is missing, or the required macOS spawn helper is missing.
|
||||
Invalid download metadata raises ValueError. Missing executable messages name
|
||||
the acquisition routes (acquisition strategy is deliberately separate from
|
||||
this lookup interface, so an on-demand download can replace it without
|
||||
touching callers).
|
||||
@@ -92,22 +91,7 @@ def bundled_runtime_path() -> Path:
|
||||
f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. "
|
||||
+ _EXE_ACQUISITION_HINT
|
||||
)
|
||||
office = path.with_name(f"{path.name.removesuffix('.exe')}-office")
|
||||
adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json"
|
||||
if not adapter.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. "
|
||||
+ _EXE_ACQUISITION_HINT
|
||||
)
|
||||
native = tag.replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {})
|
||||
engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm"
|
||||
if not (office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json").is_file():
|
||||
raise FileNotFoundError(
|
||||
f"deepseek-harness-runtime-bin is missing the Office sidecar engine {engine} at {office}. "
|
||||
+ _EXE_ACQUISITION_HINT
|
||||
)
|
||||
validate_resources(path.with_name(tag), tag)
|
||||
validate_downloads(path.with_name(tag), tag)
|
||||
return path
|
||||
|
||||
|
||||
@@ -152,14 +136,7 @@ def _current_platform_tag() -> str:
|
||||
|
||||
def _node_launch_args() -> tuple[str, str]:
|
||||
node_root = bundled_package_dir() / "runtime" / "node"
|
||||
bin_js = (
|
||||
node_root
|
||||
/ "node_modules"
|
||||
/ "@deepseek-ai"
|
||||
/ "dsh"
|
||||
/ "lib"
|
||||
/ "bin.js"
|
||||
)
|
||||
bin_js = node_root / "runtime-bootstrap.mjs"
|
||||
if not bin_js.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"the dev-only node runtime closure is missing at {node_root} "
|
||||
@@ -176,6 +153,43 @@ def _node_launch_args() -> tuple[str, str]:
|
||||
return (node, str(bin_js))
|
||||
|
||||
|
||||
def _download(flag: str) -> Path:
|
||||
result = subprocess.run((*resolve_bundled_launch_args(), flag), capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError(result.stderr.strip() or f"resource download failed with exit code {result.returncode}")
|
||||
path = json.loads(result.stdout)
|
||||
if not isinstance(path, str) or not Path(path).is_absolute():
|
||||
raise ValueError("runtime returned an invalid resource path")
|
||||
return Path(path)
|
||||
|
||||
|
||||
def download_office() -> Path:
|
||||
"""Download the locked npm Office sidecar and standalone Node into the resource cache.
|
||||
|
||||
Return its absolute directory. Reuse completed downloads; failures leave no
|
||||
published partial installation. DSH_RESOURCE_CACHE selects the cache root.
|
||||
This explicit operation requires network access on a cache miss, not a
|
||||
Harness home, agent, or model. Other SDK operations never trigger downloads.
|
||||
"""
|
||||
return _download("--download-office")
|
||||
|
||||
|
||||
def download_primary_runtime() -> Path:
|
||||
"""Download locked CPython, libraries, Node, pnpm and skills into the resource cache.
|
||||
|
||||
Return the absolute primary-runtime directory. This is independent of the
|
||||
Office engine download and does not copy resources into a Harness home.
|
||||
Completed downloads are reused; DSH_RESOURCE_CACHE selects the cache root.
|
||||
"""
|
||||
return _download("--download-primary-runtime")
|
||||
|
||||
|
||||
def resolve_office_launch_args() -> tuple[str, str]:
|
||||
"""Locate the explicitly downloaded Office CLI and its standalone Node; never download."""
|
||||
tag = _current_platform_tag()
|
||||
return office_launch_args(bundled_runtime_path().with_name(tag), tag)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the CLI with explicit DSH_HOME; wait on Windows, replace the process on POSIX."""
|
||||
if not os.environ.get("DSH_HOME", "").strip():
|
||||
@@ -197,6 +211,9 @@ __all__ = [
|
||||
"RUNTIME_MODE_ENV_VAR",
|
||||
"bundled_package_dir",
|
||||
"bundled_runtime_path",
|
||||
"download_office",
|
||||
"download_primary_runtime",
|
||||
"resolve_office_launch_args",
|
||||
"main",
|
||||
"resolve_bundled_launch_args",
|
||||
]
|
||||
@@ -3,6 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
@@ -41,3 +45,71 @@ def validate_resources(root: Path | zipfile.Path, target: str) -> None:
|
||||
if isinstance(binary, zipfile.Path) else binary.stat().st_mode)
|
||||
if mode & stat.S_IXUSR == 0:
|
||||
raise ValueError(f"runtime interpreter lost its executable bit: {binary}")
|
||||
|
||||
|
||||
def validate_downloads(root: Path | zipfile.Path, target: str) -> dict:
|
||||
"""Validate the lightweight download manifest carried by one platform wheel."""
|
||||
path = root / "downloads.json"
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (not isinstance(value, dict) or value.get("target") != target.replace("macos-", "mac-")
|
||||
or not isinstance(value.get("identity"), str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", value["identity"])
|
||||
or not isinstance(value.get("office"), list) or not value["office"]
|
||||
or not isinstance(value.get("pnpm"), dict)):
|
||||
raise ValueError(f"invalid runtime download manifest: {path}")
|
||||
for file in ["scripts/check_office.py", *(f"office-{kind}/SKILL.md" for kind in ("docx", "pptx", "xlsx"))]:
|
||||
if not (root / "office-skills" / file).is_file():
|
||||
raise FileNotFoundError(f"runtime Office skill is missing: {root / 'office-skills' / file}")
|
||||
return value
|
||||
|
||||
|
||||
def office_launch_args(root: Path, target: str) -> tuple[str, str]:
|
||||
"""Require an explicitly installed sidecar, including its independent Node interpreter."""
|
||||
metadata = validate_downloads(root, target)
|
||||
cache = Path(os.environ.get("DSH_RESOURCE_CACHE", str(Path.home() / ".cache/deepseek-harness/resources")))
|
||||
if not cache.is_absolute():
|
||||
raise ValueError("DSH_RESOURCE_CACHE must be an absolute directory")
|
||||
office = Path(os.environ.get("DSH_OFFICE_SIDECAR", str(cache / metadata["identity"] / "office")))
|
||||
if not office.is_absolute():
|
||||
raise ValueError("DSH_OFFICE_SIDECAR must be an absolute directory")
|
||||
node = office / "node/bin" / ("node.exe" if target == "win-x64" else "node")
|
||||
cli = office / "node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js"
|
||||
for path in (node, cli):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Office resource is missing: {path}. Call deepseek_harness_runtime.download_office() first.")
|
||||
return str(node), str(cli)
|
||||
|
||||
|
||||
def run_office_process(arguments: Sequence[str], *, capture_output: bool) -> subprocess.CompletedProcess[bytes]:
|
||||
"""Run Office with captured or inherited streams; a second interrupt kills its process tree."""
|
||||
taskkill = os.path.join(os.environ.get("SystemRoot", ""), "System32", "taskkill.exe")
|
||||
if os.name == "nt" and not os.path.isabs(taskkill):
|
||||
raise ValueError("Office operations on Windows require an absolute SystemRoot environment value.")
|
||||
with subprocess.Popen(
|
||||
arguments,
|
||||
stdin=subprocess.DEVNULL if capture_output else None,
|
||||
stdout=subprocess.PIPE if capture_output else None,
|
||||
stderr=subprocess.PIPE if capture_output else None,
|
||||
start_new_session=os.name != "nt",
|
||||
) as process:
|
||||
try:
|
||||
stdout, stderr = process.communicate()
|
||||
except KeyboardInterrupt:
|
||||
# Windows console Ctrl+C already reaches children in the same console.
|
||||
if os.name != "nt":
|
||||
process.send_signal(signal.SIGINT)
|
||||
try:
|
||||
process.communicate()
|
||||
except KeyboardInterrupt:
|
||||
if os.name == "nt":
|
||||
subprocess.run([taskkill, "/PID", str(process.pid), "/T", "/F"],
|
||||
stdin=subprocess.DEVNULL, capture_output=True, check=False)
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError as error:
|
||||
# The CLI and its children can finish before the second interrupt.
|
||||
pass
|
||||
process.communicate()
|
||||
raise
|
||||
return subprocess.CompletedProcess(arguments, process.returncode, stdout, stderr)
|
||||
@@ -5,6 +5,9 @@
|
||||
/deepseek-harness-python-sdk:
|
||||
en: 43f7de064985e731
|
||||
zh: 9ad59629660b1f13
|
||||
/deepseek-harness-python-sdk/optional-office-operations:
|
||||
en: ddf4dabea526cef1
|
||||
zh: 6e31cab10d2f3ed8
|
||||
/deepseek-harness-python-sdk/start-a-runtime:
|
||||
en: 9a069c1a7cbb0779
|
||||
zh: 7f625a802cd1e9ea
|
||||
|
||||
@@ -8,6 +8,24 @@ Python subprocess SDK for driving DeepSeek Harness over newline-delimited JSON-R
|
||||
python -m pip install deepseek-harness-sdk
|
||||
```
|
||||
|
||||
## Optional Office operations
|
||||
|
||||
Download the Office runtime explicitly, then call the local API without an agent, Session, model request, API key, or Harness home:
|
||||
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office
|
||||
from deepseek_harness.office import convert, recalculate, render_images
|
||||
|
||||
download_office()
|
||||
pdf = convert("report.docx", "report.pdf")
|
||||
workbook = recalculate("forecast.xlsx", "calculated.xlsx")
|
||||
preview = render_images("report.docx", "report-preview", pages=[1])
|
||||
```
|
||||
|
||||
The output file or image directory must not exist. Successful outputs belong to the caller; Kit removes failed outputs. `OfficeOptions` supplies operation limits and font preferences. Results expose typed paths, missing fonts, and image metadata. `OfficeError` retains CLI failure codes; malformed results raise `SdkProtocolError`. Recalculation does not verify business logic or Excel fidelity. Ctrl+C waits for CLI cleanup; a second interrupt forces termination and may leave partial output. Windows requires an absolute `SystemRoot` and a shared console for Ctrl+C delivery.
|
||||
|
||||
The authoring environment is independently optional through `deepseek_harness_runtime.download_primary_runtime()`. See the [runtime reference](../sdk-runtime/README.md) for cache selection, resource activation, and download behavior. SDK startup and Office operations never acquire missing resources automatically.
|
||||
|
||||
## Start a runtime
|
||||
|
||||
The Python SDK has no separate application entrypoint. It launches the bundled `dsh` CLI with `--profile sdk`; the selected profile owns the JSON-RPC server, agent composition, credentials, persistence, tools, and shutdown behavior.
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
python -m pip install deepseek-harness-sdk
|
||||
```
|
||||
|
||||
## 可选 Office 操作
|
||||
|
||||
显式下载 Office 运行时后,即可调用本地 API,无需 agent、Session、模型请求、API key 或 Harness home:
|
||||
|
||||
```py
|
||||
from deepseek_harness_runtime import download_office
|
||||
from deepseek_harness.office import convert, recalculate, render_images
|
||||
|
||||
download_office()
|
||||
pdf = convert("report.docx", "report.pdf")
|
||||
workbook = recalculate("forecast.xlsx", "calculated.xlsx")
|
||||
preview = render_images("report.docx", "report-preview", pages=[1])
|
||||
```
|
||||
|
||||
输出文件或图片目录必须不存在。调用方拥有成功输出;Kit 删除失败输出。`OfficeOptions` 提供操作限制和字体偏好。结果包含类型化路径、缺失字体和图片元数据。`OfficeError` 保留 CLI 失败代码;无效结果抛出 `SdkProtocolError`。重算不验证业务逻辑或 Excel 兼容性。Ctrl+C 会等待 CLI 清理;再次中断会强制终止,可能留下未完成输出。Windows 要求绝对 `SystemRoot` 路径,并通过共享控制台传递 Ctrl+C。
|
||||
|
||||
创作环境可通过 `deepseek_harness_runtime.download_primary_runtime()` 独立选择。缓存选择、资源启用与下载行为见[运行时参考](../sdk-runtime/README.zh.md)。SDK 启动和 Office 操作都不会自动获取缺失资源。
|
||||
|
||||
## 启动运行时
|
||||
|
||||
Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 `dsh` CLI(命令行界面);所选 profile 负责 JSON-RPC 服务器、agent(智能体)组合、凭据、持久化、工具和关闭流程。
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Local Office operations through the runtime wheel's dsoffice CLI.
|
||||
|
||||
Calls are synchronous and do not create agents, sessions, or model requests.
|
||||
The CLI owns conversion deadlines, engine termination, and failed-output cleanup.
|
||||
Console Ctrl+C waits for CLI cleanup before raising KeyboardInterrupt; a second
|
||||
interrupt forces termination and can leave partial output. On Windows, callers
|
||||
must share the CLI's console for Ctrl+C delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypeVar
|
||||
|
||||
import deepseek_harness_runtime
|
||||
from deepseek_harness_runtime._resources import run_office_process
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from .errors import HarnessError, SdkProtocolError
|
||||
|
||||
|
||||
class OfficeError(HarnessError):
|
||||
"""A failed Office operation, including its CLI error code and exit status."""
|
||||
|
||||
def __init__(self, code: str, message: str, exit_code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class _OfficeModel(BaseModel):
|
||||
model_config = ConfigDict(strict=True, frozen=True)
|
||||
|
||||
|
||||
class ConversionResult(_OfficeModel):
|
||||
"""Saved conversion or recalculation output and missing declared fonts."""
|
||||
|
||||
backend: Literal["native", "wasm"]
|
||||
output_path: Path = Field(alias="outputPath")
|
||||
missing_fonts: list[str] = Field(alias="missingFonts")
|
||||
|
||||
|
||||
class ImageRectangle(_OfficeModel):
|
||||
"""Source rectangle in CSS pixels at 96 DPI, before raster scaling."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class RenderedImage(_OfficeModel):
|
||||
"""One saved PNG and its source page, worksheet, or worksheet fragment."""
|
||||
|
||||
index: int
|
||||
path: Path
|
||||
width: int
|
||||
height: int
|
||||
rectangle: ImageRectangle
|
||||
byte_length: int = Field(alias="byteLength")
|
||||
page: int | None = None
|
||||
sheet: str | None = None
|
||||
range: str | None = None
|
||||
rtl: bool | None = None
|
||||
|
||||
|
||||
class RenderImagesResult(_OfficeModel):
|
||||
"""Image manifest for one saved input; page_count includes unselected pages."""
|
||||
|
||||
schema_version: Literal[1] = Field(alias="schemaVersion")
|
||||
backend: Literal["native", "wasm"]
|
||||
raster_engine: Literal["libreoffice", "pdfium"] = Field(alias="rasterEngine")
|
||||
source: Literal["saved"]
|
||||
input_path: Path = Field(alias="inputPath")
|
||||
source_sha256: str = Field(alias="sourceSha256")
|
||||
dpi: float
|
||||
page_count: int = Field(alias="pageCount")
|
||||
images: list[RenderedImage]
|
||||
missing_fonts: list[str] = Field(alias="missingFonts")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OfficeOptions:
|
||||
"""Kit limits and fonts; omitted values retain the installed kit defaults.
|
||||
|
||||
timeout_ms bounds the engine operation, including font work and output,
|
||||
after explicit resource download. The call waits for engine exit and cleanup.
|
||||
Font paths may be relative to cwd; fallback groups replace the kit defaults.
|
||||
"""
|
||||
|
||||
timeout_ms: int | None = None
|
||||
max_input_bytes: int | None = None
|
||||
max_output_bytes: int | None = None
|
||||
max_image_resolution: int | None = None
|
||||
max_archive_entries: int | None = None
|
||||
max_uncompressed_bytes: int | None = None
|
||||
max_font_files: int | None = None
|
||||
max_font_file_bytes: int | None = None
|
||||
max_loaded_font_bytes: int | None = None
|
||||
font_directories: Sequence[str | os.PathLike[str]] | None = None
|
||||
initial_font_families: Sequence[str] | None = None
|
||||
font_fallbacks: Sequence[Sequence[str]] | None = None
|
||||
|
||||
def _arguments(self) -> list[str]:
|
||||
arguments: list[str] = []
|
||||
for field in fields(self):
|
||||
value = getattr(self, field.name)
|
||||
if value is None:
|
||||
continue
|
||||
if field.name == "font_directories":
|
||||
for path in value:
|
||||
arguments.extend(("--font-directory", os.fspath(path)))
|
||||
elif field.name == "initial_font_families":
|
||||
for family in value:
|
||||
arguments.extend(("--initial-font-family", family))
|
||||
elif field.name == "font_fallbacks":
|
||||
arguments.extend(("--font-fallbacks", json.dumps(value)))
|
||||
else:
|
||||
arguments.extend(("--" + field.name.replace("_", "-"), str(value)))
|
||||
return arguments
|
||||
|
||||
|
||||
class _CliFailure(_OfficeModel):
|
||||
code: str
|
||||
error: str
|
||||
|
||||
|
||||
_Result = TypeVar("_Result", bound=_OfficeModel)
|
||||
|
||||
|
||||
def _run(arguments: list[str], options: OfficeOptions | None, result_type: type[_Result]) -> _Result:
|
||||
process = run_office_process(
|
||||
[*deepseek_harness_runtime.resolve_office_launch_args(), *arguments,
|
||||
*(options._arguments() if options is not None else [])],
|
||||
capture_output=True,
|
||||
)
|
||||
stdout, stderr = process.stdout, process.stderr
|
||||
if process.returncode != 0:
|
||||
try:
|
||||
failure = _CliFailure.model_validate_json(stderr)
|
||||
except ValidationError:
|
||||
raise OfficeError("failed", stderr.decode("utf-8", errors="replace").strip()
|
||||
or "Office CLI exited without a diagnostic.",
|
||||
process.returncode) from None
|
||||
raise OfficeError(failure.code, failure.error, process.returncode)
|
||||
try:
|
||||
return result_type.model_validate_json(stdout)
|
||||
except ValidationError as error:
|
||||
raise SdkProtocolError("Office CLI returned an invalid result.") from error
|
||||
|
||||
|
||||
def render_images(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_dir: str | os.PathLike[str],
|
||||
*,
|
||||
pages: Literal["all"] | Sequence[int] | None = None,
|
||||
sheet: str | None = None,
|
||||
range: str | None = None,
|
||||
dpi: int | None = None,
|
||||
max_pages: int | None = None,
|
||||
max_pixels: int | None = None,
|
||||
max_dimension: int | None = None,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> RenderImagesResult:
|
||||
"""Render Office or PDF to PNG files and manifest.json in a fresh directory.
|
||||
|
||||
Pages are one-based document pages or slides. Worksheets use an exact
|
||||
sheet name and optional A1 range instead. Paths may be relative to cwd.
|
||||
The caller owns successful output; failed batches remove the new directory.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
arguments = ["render", "--input", os.fspath(input_path), "--output-dir", os.fspath(output_dir)]
|
||||
selections = {
|
||||
"pages": pages if isinstance(pages, str) or pages is None else ",".join(map(str, pages)),
|
||||
"sheet": sheet, "range": range, "dpi": dpi, "max-pages": max_pages,
|
||||
"max-pixels": max_pixels, "max-dimension": max_dimension,
|
||||
}
|
||||
for name, value in selections.items():
|
||||
if value is not None:
|
||||
arguments.extend(("--" + name, str(value)))
|
||||
return _run(arguments, options, RenderImagesResult)
|
||||
|
||||
|
||||
def convert(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_path: str | os.PathLike[str],
|
||||
*,
|
||||
sheet: str | None = None,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Export Office to the format named by a fresh output file's extension.
|
||||
|
||||
Paths may be relative to cwd. CSV export requires an exact sheet name for
|
||||
multi-sheet input. Existing output is rejected; failed output is removed.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
arguments = ["convert", "--input", os.fspath(input_path), "--output", os.fspath(output_path)]
|
||||
if sheet is not None:
|
||||
arguments.extend(("--sheet", sheet))
|
||||
return _run(arguments, options, ConversionResult)
|
||||
|
||||
|
||||
def recalculate(
|
||||
input_path: str | os.PathLike[str],
|
||||
output_path: str | os.PathLike[str],
|
||||
*,
|
||||
options: OfficeOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Refresh workbook formula caches into a distinct, fresh XLSX or ODS file.
|
||||
|
||||
Input accepts XLS, XLSX, or ODS. Paths may be relative to cwd. Formulas
|
||||
survive, but recalculation does not verify business logic or Excel fidelity.
|
||||
Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON.
|
||||
"""
|
||||
return _run(["recalculate", "--input", os.fspath(input_path), "--output", os.fspath(output_path)],
|
||||
options, ConversionResult)
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
"""Office JSON subprocess peer; engine behavior belongs to the real kit smoke."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
receipt = Path(sys.argv[1])
|
||||
arguments = sys.argv[2:]
|
||||
receipt.write_text(json.dumps(arguments), encoding="utf-8")
|
||||
command = arguments[0]
|
||||
values = dict(zip(arguments[1::2], arguments[2::2]))
|
||||
source = Path(values["--input"])
|
||||
if source.name == "error.docx":
|
||||
print(json.dumps({"code": "invalid-document", "error": "Document cannot be loaded."}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if source.name == "crash.docx":
|
||||
print("engine process unavailable", file=sys.stderr)
|
||||
sys.exit(7)
|
||||
if source.name == "invalid.docx":
|
||||
print(json.dumps({"backend": "native", "outputPath": 3, "missingFonts": []}))
|
||||
sys.exit(0)
|
||||
if source.name == "invalid-bytes.docx":
|
||||
sys.stdout.buffer.write(b"\xff")
|
||||
sys.exit(0)
|
||||
if source.name == "timeout.docx":
|
||||
output = Path(values["--output"])
|
||||
output.write_text("partial output", encoding="utf-8")
|
||||
output.unlink()
|
||||
print(json.dumps({"code": "timeout", "error": "Conversion timed out."}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if command == "render":
|
||||
output = Path(values["--output-dir"])
|
||||
output.mkdir()
|
||||
image = output / "page-1.png"
|
||||
image.write_bytes(b"png fixture")
|
||||
result = {
|
||||
"schemaVersion": 1, "backend": "native", "rasterEngine": "libreoffice", "source": "saved",
|
||||
"inputPath": str(source.resolve()), "sourceSha256": "abc123", "dpi": 144, "pageCount": 3,
|
||||
"images": [{"index": 0, "page": 1, "path": str(image.resolve()), "width": 64, "height": 48,
|
||||
"rectangle": {"x": 0, "y": 0, "width": 32, "height": 24}, "byteLength": 11}],
|
||||
"missingFonts": ["Fixture Sans"],
|
||||
}
|
||||
(output / "manifest.json").write_text(json.dumps(result), encoding="utf-8")
|
||||
else:
|
||||
output = Path(values["--output"])
|
||||
output.write_text("converted fixture", encoding="utf-8")
|
||||
result = {"backend": "wasm", "outputPath": str(output.resolve()), "missingFonts": []}
|
||||
print(json.dumps(result))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Exercise Office argv and JSON through an actual subprocess without a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import deepseek_harness_runtime
|
||||
import pytest
|
||||
|
||||
from deepseek_harness.errors import SdkProtocolError
|
||||
from deepseek_harness.office import ConversionResult, OfficeError, OfficeOptions, convert, recalculate, render_images
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def office_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
receipt = tmp_path / "arguments.json"
|
||||
script = Path(__file__).parent / "fixtures" / "office_cli.py"
|
||||
monkeypatch.setattr(deepseek_harness_runtime, "resolve_office_launch_args",
|
||||
lambda: (sys.executable, str(script), str(receipt)))
|
||||
return receipt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", [convert, recalculate])
|
||||
def test_saved_output_and_literal_path_arguments(
|
||||
operation: Callable[..., ConversionResult], office_cli: Path, tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "工作簿 $name with spaces.xlsx"
|
||||
output = tmp_path / "saved workbook.xlsx"
|
||||
result = operation(source, output)
|
||||
assert result.output_path == output
|
||||
assert output.read_text() == "converted fixture"
|
||||
assert result.backend == "wasm"
|
||||
assert result.missing_fonts == []
|
||||
assert json.loads(office_cli.read_text()) == [operation.__name__, "--input", str(source), "--output", str(output)]
|
||||
|
||||
|
||||
def test_render_images_reads_the_saved_manifest(office_cli: Path, tmp_path: Path) -> None:
|
||||
output = tmp_path / "preview"
|
||||
result = render_images(tmp_path / "deck.pptx", output, pages=[1, 3], dpi=144, max_pages=10,
|
||||
max_pixels=1_000_000, max_dimension=4096)
|
||||
assert result.page_count == 3
|
||||
assert result.images[0].path.read_bytes() == b"png fixture"
|
||||
assert result.images[0].rectangle.width == 32
|
||||
assert result.images[0].byte_length == 11
|
||||
assert result.missing_fonts == ["Fixture Sans"]
|
||||
assert result.input_path == tmp_path / "deck.pptx"
|
||||
assert json.loads(office_cli.read_text())[5:] == [
|
||||
"--pages", "1,3", "--dpi", "144", "--max-pages", "10", "--max-pixels", "1000000", "--max-dimension", "4096",
|
||||
]
|
||||
|
||||
|
||||
def test_worksheet_selection_and_csv_sheet_keep_exact_names(office_cli: Path, tmp_path: Path) -> None:
|
||||
sheet = "收入 $Values"
|
||||
render_images("workbook.xlsx", tmp_path / "preview", sheet=sheet, range="A1:E9")
|
||||
assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet, "--range", "A1:E9"]
|
||||
convert("workbook.xlsx", tmp_path / "data.csv", sheet=sheet)
|
||||
assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet]
|
||||
|
||||
|
||||
def test_limits_and_fonts_reach_the_cli(office_cli: Path, tmp_path: Path) -> None:
|
||||
convert("report.docx", tmp_path / "report.pdf", options=OfficeOptions(
|
||||
timeout_ms=4567, max_input_bytes=1024, max_output_bytes=2048, max_image_resolution=72,
|
||||
max_archive_entries=10, max_uncompressed_bytes=4096, max_font_files=20,
|
||||
max_font_file_bytes=8192, max_loaded_font_bytes=16384,
|
||||
font_directories=[tmp_path / "Fonts One", tmp_path / "Fonts Two"],
|
||||
initial_font_families=["Fixture Sans", "中文字体"], font_fallbacks=[["Preferred", "Fallback"]],
|
||||
))
|
||||
assert json.loads(office_cli.read_text())[5:] == [
|
||||
"--timeout-ms", "4567", "--max-input-bytes", "1024", "--max-output-bytes", "2048",
|
||||
"--max-image-resolution", "72", "--max-archive-entries", "10", "--max-uncompressed-bytes", "4096",
|
||||
"--max-font-files", "20", "--max-font-file-bytes", "8192", "--max-loaded-font-bytes", "16384",
|
||||
"--font-directory", str(tmp_path / "Fonts One"), "--font-directory", str(tmp_path / "Fonts Two"),
|
||||
"--initial-font-family", "Fixture Sans", "--initial-font-family", "中文字体",
|
||||
"--font-fallbacks", '[["Preferred", "Fallback"]]',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("filename", "code", "exit_code", "message"), [
|
||||
("error.docx", "invalid-document", 1, "Document cannot be loaded"),
|
||||
("crash.docx", "failed", 7, "engine process unavailable"),
|
||||
("timeout.docx", "timeout", 1, "Conversion timed out"),
|
||||
])
|
||||
def test_cli_failures_preserve_diagnostics_after_exit(
|
||||
office_cli: Path, tmp_path: Path, filename: str, code: str, exit_code: int, message: str,
|
||||
) -> None:
|
||||
output = tmp_path / "result.pdf"
|
||||
with pytest.raises(OfficeError, match=message) as failure:
|
||||
convert(filename, output, options=OfficeOptions(timeout_ms=100))
|
||||
assert failure.value.code == code
|
||||
assert failure.value.exit_code == exit_code
|
||||
assert not output.exists()
|
||||
assert json.loads(office_cli.read_text())[-2:] == ["--timeout-ms", "100"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["invalid.docx", "invalid-bytes.docx"])
|
||||
def test_invalid_cli_result_fails_at_the_json_reader(office_cli: Path, tmp_path: Path, filename: str) -> None:
|
||||
with pytest.raises(SdkProtocolError, match="Office CLI returned an invalid result"):
|
||||
convert(filename, tmp_path / "result.pdf")
|
||||
@@ -145,7 +145,7 @@ def test_stage_runtime_copies_platform_payload(
|
||||
spawn_helper.write_bytes(b"helper")
|
||||
spawn_helper.chmod(0o755)
|
||||
expected[spawn_helper.name] = b"helper"
|
||||
office = executable.parent / build_python_release.office_sidecar_name(executable.name)
|
||||
office = executable.parent / f"{executable.name.removesuffix('.exe')}-office"
|
||||
office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data"
|
||||
office_asset.parent.mkdir(parents=True)
|
||||
office_asset.write_bytes(b"office data")
|
||||
@@ -163,7 +163,7 @@ def test_stage_runtime_copies_platform_payload(
|
||||
for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*")
|
||||
if path.is_file()
|
||||
} == expected
|
||||
assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data"
|
||||
assert not (runtime_dir / office.name).exists()
|
||||
assert (runtime_dir / resources.name / resource.relative_to(resources)).read_text() == "checker"
|
||||
pyproject = (destination / "pyproject.toml").read_text()
|
||||
assert 'license = "MIT"' in pyproject
|
||||
@@ -191,43 +191,9 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) ->
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_tag,selected", [
|
||||
("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"),
|
||||
("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"),
|
||||
("manylinux_2_28_x86_64", "linux-x64"), ("macosx_14_0_arm64", "wasm"),
|
||||
])
|
||||
@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"])
|
||||
def test_office_wheel_requires_only_target_engine(
|
||||
tmp_path: Path, platform_tag: str, selected: str, invalid: str | None,
|
||||
) -> None:
|
||||
root = "runtime-office/node_modules"
|
||||
wheel = tmp_path / "office.zip"
|
||||
native = selected != "wasm"
|
||||
if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"):
|
||||
pytest.skip("executable mode applies to POSIX native helpers")
|
||||
engine = ({"kind": "native", "executable": "bin/helper"} if native else
|
||||
{"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"})
|
||||
with zipfile.ZipFile(wheel, "w") as archive:
|
||||
archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", json.dumps({
|
||||
"optionalDependencies": {f"@deepseek-ai/libreoffice-kit-{selected}": "0.0.1"},
|
||||
}))
|
||||
base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}"
|
||||
if invalid != "missing-engine":
|
||||
archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine}))
|
||||
for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")):
|
||||
if invalid == "asset" and field == ("executable" if native else "data"):
|
||||
continue
|
||||
asset = zipfile.ZipInfo(f"{base}/{engine[field]}")
|
||||
asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16
|
||||
archive.writestr(asset, b"payload")
|
||||
if invalid == "foreign-engine":
|
||||
foreign = "wasm" if native else "darwin-arm64"
|
||||
archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}")
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
if invalid is None:
|
||||
build_python_release.verify_office_payload(archive, root, platform_tag)
|
||||
else:
|
||||
message = {"asset": "asset is missing", "missing-engine": "dependency is missing",
|
||||
"foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid]
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
build_python_release.verify_office_payload(archive, root, platform_tag)
|
||||
def test_wheel_verification_rejects_oversized_artifacts(tmp_path: Path) -> None:
|
||||
wheel = tmp_path / "oversized.whl"
|
||||
with wheel.open("wb") as output:
|
||||
output.truncate(100_000_001)
|
||||
with pytest.raises(RuntimeError, match="PyPI single-file limit"):
|
||||
build_python_release.verify_wheel(wheel, "runtime", "1.2.3", ("win_amd64", "runtime.exe"))
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,11 @@ def _resource_sidecars(executable: Path, native_targets: tuple[str, ...] = ("dar
|
||||
f"@deepseek-ai/libreoffice-kit-{target}": "0.0.1" for target in (*native_targets, "wasm")
|
||||
}}), encoding="utf-8")
|
||||
resources = executable.with_name(tag)
|
||||
resources.mkdir(parents=True, exist_ok=True)
|
||||
(resources / "downloads.json").write_text(json.dumps({
|
||||
"target": tag.replace("macos-", "mac-"), "identity": "a" * 64,
|
||||
"office": [{"name": "fixture"}], "pnpm": {},
|
||||
}))
|
||||
manifest = resources / "primary-runtime/runtime.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text(json.dumps({"platform": native.rsplit("-", 1)[0], "arch": tag.rsplit("-", 1)[1],
|
||||
@@ -195,19 +201,20 @@ def test_runtime_requires_complete_resource_sidecars(
|
||||
monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64")
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
with pytest.raises(FileNotFoundError, match="downloads.json"):
|
||||
runtime.bundled_runtime_path()
|
||||
office = _resource_sidecars(executable)
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
(office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink()
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
runtime.bundled_runtime_path()
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
shutil.rmtree(executable.with_name("linux-x64") / "primary-runtime")
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
|
||||
|
||||
def test_node_mode_runs_the_deployed_dsh_cli(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
bin_js = tmp_path / "runtime" / "node" / "node_modules" / "@deepseek-ai" / "dsh" / "lib" / "bin.js"
|
||||
bin_js = tmp_path / "runtime" / "node" / "runtime-bootstrap.mjs"
|
||||
bin_js.parent.mkdir(parents=True)
|
||||
bin_js.touch()
|
||||
monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
|
||||
@@ -303,7 +310,7 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path:
|
||||
("macos-arm64", ("darwin-arm64",)), ("macos-x64", ("darwin-x64",)), ("win-x64", ("win32-x64",)),
|
||||
("linux-x64", ("linux-x64",)), ("macos-arm64", ()),
|
||||
])
|
||||
def test_runtime_requires_its_platform_office_engine(
|
||||
def test_runtime_starts_independently_of_office_engine(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str, native_targets: tuple[str, ...],
|
||||
) -> None:
|
||||
extension = ".exe" if target.startswith("win-") else ""
|
||||
@@ -322,5 +329,41 @@ def test_runtime_requires_its_platform_office_engine(
|
||||
foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if engine.parent.name == "libreoffice-kit-wasm" else "libreoffice-kit-wasm") / "prebuilds.json"
|
||||
foreign.parent.mkdir(parents=True, exist_ok=True)
|
||||
foreign.write_text("{}")
|
||||
with pytest.raises(FileNotFoundError, match="Office sidecar"):
|
||||
runtime.bundled_runtime_path()
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
|
||||
|
||||
@pytest.mark.parametrize("function,flag", [
|
||||
(runtime.download_office, "--download-office"),
|
||||
(runtime.download_primary_runtime, "--download-primary-runtime"),
|
||||
])
|
||||
def test_explicit_download_uses_runtime_without_a_harness_home(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, function, flag: str,
|
||||
) -> None:
|
||||
script = tmp_path / "download.py"
|
||||
destination = tmp_path / "downloaded"
|
||||
script.write_text("import json,sys\n" + f"assert sys.argv[1] == {flag!r}\nprint(json.dumps({str(destination)!r}))\n")
|
||||
monkeypatch.delenv("DSH_HOME", raising=False)
|
||||
monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: (sys.executable, str(script)))
|
||||
assert function() == destination
|
||||
script.write_text("import sys\nprint('checksum mismatch', file=sys.stderr)\nsys.exit(2)\n")
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
function()
|
||||
|
||||
|
||||
def test_office_resolution_requires_explicit_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from deepseek_harness_runtime._resources import office_launch_args
|
||||
|
||||
executable = tmp_path / "deepseek-harness-sdk-runtime-linux-x64"
|
||||
_resource_sidecars(executable)
|
||||
cache = tmp_path / "cache"
|
||||
monkeypatch.setenv("DSH_RESOURCE_CACHE", str(cache))
|
||||
monkeypatch.delenv("DSH_OFFICE_SIDECAR", raising=False)
|
||||
with pytest.raises(FileNotFoundError, match=r"download_office\(\)"):
|
||||
office_launch_args(tmp_path / "linux-x64", "linux-x64")
|
||||
office = cache / ("a" * 64) / "office"
|
||||
node = office / "node/bin/node"
|
||||
cli = office / "node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js"
|
||||
for path in (node, cli):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
assert office_launch_args(tmp_path / "linux-x64", "linux-x64") == (str(node), str(cli))
|
||||
@@ -2,7 +2,9 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/pr
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { c as tar } from 'tar'
|
||||
import { copyOfficeSidecar, downloadOfficeSidecar } from './build-exe-for-python-sdk-office.ts'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
@@ -139,3 +141,28 @@ it('requires a staged Linux WASM engine even when an ancestor has one', async ()
|
||||
.rejects.toThrow('Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.')
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('downloads pinned npm packages into the sidecar without resolving or executing package scripts', async () => {
|
||||
const { root, destination } = await fixture()
|
||||
const source = join(root, 'package')
|
||||
await mkdir(source)
|
||||
await writeFile(join(source, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { install: 'must-not-run' } }))
|
||||
await writeFile(join(source, 'worker.js'), 'worker')
|
||||
const archive = join(root, 'package.tgz')
|
||||
await tar({ file: archive, cwd: root, gzip: true }, ['package'])
|
||||
const bytes = await readFile(archive)
|
||||
const checksum = createHash('sha512').update(bytes).digest()
|
||||
const cache = join(root, 'archives')
|
||||
await mkdir(cache)
|
||||
await writeFile(join(cache, checksum.toString('hex')), bytes)
|
||||
const artifact = { name: 'fixture', version: '1.0.0', directory: 'node_modules/fixture',
|
||||
url: 'https://unused.invalid/fixture.tgz', integrity: `sha512-${checksum.toString('base64')}` }
|
||||
await downloadOfficeSidecar([artifact], destination, cache)
|
||||
expect(await readFile(join(destination, 'node_modules/fixture/worker.js'), 'utf8')).toBe('worker')
|
||||
await expect(downloadOfficeSidecar([{ ...artifact, directory: 'node_modules/../../outside' }], destination, cache))
|
||||
.rejects.toThrow('invalid package directory')
|
||||
await expect(downloadOfficeSidecar([{ ...artifact, version: '2.0.0' }], destination, cache))
|
||||
.rejects.toThrow('package identity mismatch')
|
||||
await writeFile(join(cache, checksum.toString('hex')), 'corrupted archive')
|
||||
await expect(downloadOfficeSidecar([artifact], destination, cache)).rejects.toThrow('checksum mismatch')
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { cp, mkdir, readFile, rm } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import { downloadRuntimeNpmPackage, type RuntimeNpmArchive } from './primary-runtime/prepare.ts'
|
||||
import { officePackageDirectories } from './libreoffice-packages.mjs'
|
||||
|
||||
/** pkg applies these exclusions to dependency `files` as well as root asset globs. */
|
||||
@@ -35,3 +36,54 @@ export async function copyOfficeSidecar(
|
||||
}
|
||||
return directories.map(directory => relative(staging, directory))
|
||||
}
|
||||
|
||||
/** One npm package at its existing sidecar dependency location. */
|
||||
export interface OfficePackageArchive extends RuntimeNpmArchive {
|
||||
/** Relative package directory inside the sidecar. */
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read exact npm tarball metadata for a version already selected by the workspace lock.
|
||||
* @param name - Published package name.
|
||||
* @param version - Exact installed version.
|
||||
* @returns Immutable npm tarball identity.
|
||||
*/
|
||||
export async function runtimeNpmArchive(name: string, version: string): Promise<RuntimeNpmArchive> {
|
||||
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`)
|
||||
if (!response.ok) throw new Error(`runtime npm metadata: HTTP ${String(response.status)} for ${name}@${version}`)
|
||||
const metadata = await response.json() as { name: string; version: string; dist: { tarball: string; integrity: string } }
|
||||
if (metadata.name !== name || metadata.version !== version || !metadata.dist.integrity.startsWith('sha512-')) {
|
||||
throw new Error(`runtime npm metadata: invalid identity for ${name}@${version}`)
|
||||
}
|
||||
return { name, version, url: metadata.dist.tarball, integrity: metadata.dist.integrity }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the installed Office closure to published npm tarballs, preserving nested dependency locations.
|
||||
* @param staging - Complete installed Node closure.
|
||||
* @param target - Sidecar platform and CPU.
|
||||
* @returns Download records for the selected engine and all dependencies.
|
||||
*/
|
||||
export async function officeSidecarArchives(staging: string, target: { platform: string; arch: string }): Promise<OfficePackageArchive[]> {
|
||||
const directories = await officePackageDirectories(staging, target)
|
||||
return Promise.all(directories.map(async (directory) => {
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { name: string; version: string }
|
||||
return { ...await runtimeNpmArchive(manifest.name, manifest.version), directory: relative(staging, directory).split(sep).join('/') }
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the complete pinned Office sidecar without npm or package lifecycle scripts.
|
||||
* @param packages - Build-locked npm packages and dependency locations.
|
||||
* @param destination - Empty sidecar directory.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after all packages are unpacked and checked.
|
||||
*/
|
||||
export async function downloadOfficeSidecar(packages: readonly OfficePackageArchive[], destination: string, cache: string): Promise<void> {
|
||||
for (const artifact of packages) {
|
||||
if (isAbsolute(artifact.directory) || artifact.directory.split(/[\\/]/u).some(part => part === '..')
|
||||
|| !artifact.directory.startsWith('node_modules/')) throw new Error(`Office download: invalid package directory ${artifact.directory}`)
|
||||
await downloadRuntimeNpmPackage(artifact, join(destination, artifact.directory), cache)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,8 @@ describe('Python runtime executable builder CLI', () => {
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('exec pkg')
|
||||
expect(result.stdout).toContain('--sea --targets node24-macos-x64')
|
||||
expect(result.stdout).toContain('prepare Python and Office skills for mac-x64')
|
||||
expect(result.stdout).toContain('keep only node-pty prebuilds darwin-x64')
|
||||
expect(result.stdout).toContain('lock optional resource downloads and copy Office skills')
|
||||
expect(result.stdout).toContain(join(root, 'dist-exe', 'macos-x64'))
|
||||
})
|
||||
|
||||
|
||||
@@ -6,17 +6,20 @@
|
||||
* runtime imports that pkg cannot discover statically.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { build as bundle } from 'tsdown'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { chmod, copyFile, cp, glob, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts'
|
||||
import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
|
||||
import { officeSidecarArchives, runtimeNpmArchive, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
|
||||
import { deduplicateStagedWorkspacePackages, materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts'
|
||||
import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts'
|
||||
import { prepareOfficeSkillAssets } from './primary-runtime/prepare.ts'
|
||||
import primaryLock from './primary-runtime/lock.json' with { type: 'json' }
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -45,6 +48,7 @@ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
*/
|
||||
const ASSET_GLOBS = [
|
||||
'package.json',
|
||||
'*.mjs',
|
||||
'node_modules/**/*.js',
|
||||
'node_modules/**/*.cjs',
|
||||
'node_modules/**/*.mjs',
|
||||
@@ -301,6 +305,9 @@ class SingleExeBuild {
|
||||
for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
|
||||
} else {
|
||||
await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
|
||||
for await (const file of glob('node_modules/**/{README*,CHANGELOG*,HISTORY*}.md', {
|
||||
cwd: this.staging, exclude: ['node_modules/**/assets/**'],
|
||||
})) await rm(join(this.staging, file), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +350,15 @@ class SingleExeBuild {
|
||||
if (!existsSync(join(this.staging, ENTRY_BIN))) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`)
|
||||
}
|
||||
await bundle({ config: false, tsconfig: false, inputOptions: { tsconfig: false }, target: 'es2024', entry: { 'primary-runtime': join(root, 'scripts/primary-runtime/prepare.ts'),
|
||||
'office-sidecar': join(root, 'scripts/build-exe-for-python-sdk-office.ts') },
|
||||
outDir: this.staging, clean: false, format: 'esm', platform: 'node', dts: false,
|
||||
deps: { neverBundle: [/^@deepseek-ai\//u], alwaysBundle: [/.*/u] }, shims: true, outExtensions: () => ({ js: '.mjs' }),
|
||||
define: { 'import.meta.main': 'false' } })
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
|
||||
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
|
||||
await this.run('resource helper imports', process.execPath, ['--input-type=module', '--eval',
|
||||
['primary-runtime.mjs', 'office-sidecar.mjs'].map(file => `await import(${JSON.stringify(pathToFileURL(join(this.staging, file)).href)})`).join('\n')])
|
||||
console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
|
||||
}
|
||||
|
||||
@@ -357,6 +371,7 @@ class SingleExeBuild {
|
||||
const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
const product = target.platform === 'win' ? `${productBase}.exe` : productBase
|
||||
await this.prepareNativePty(target)
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true })
|
||||
await this.runPnpm(`pkg ${target.spec}`, [
|
||||
'exec',
|
||||
@@ -371,26 +386,22 @@ class SingleExeBuild {
|
||||
if (!this.cli.dryRun && !existsSync(product)) {
|
||||
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
|
||||
}
|
||||
const office = `${productBase}-office`
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`)
|
||||
} else {
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch })
|
||||
console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`)
|
||||
}
|
||||
const ripgrep = await this.copyRipgrepSidecar(target, product)
|
||||
const resources = join(this.outDir, `${target.platform}-${target.arch}`)
|
||||
const runtimeTarget = `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}` as PrimaryRuntimeTarget
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] prepare Python and Office skills for ${runtimeTarget} in ${resources}`)
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] lock optional resource downloads and copy Office skills to ${resources}`)
|
||||
} else {
|
||||
const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string }
|
||||
await preparePrimaryRuntime({ target: runtimeTarget, output: resources,
|
||||
cache: join(tmpdir(), 'dsh-primary-runtime-downloads'), version })
|
||||
smokePrimaryRuntime(join(resources, 'primary-runtime'))
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
await mkdir(resources, { recursive: true })
|
||||
const { version, packageManager } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string; packageManager: string }
|
||||
const office = await officeSidecarArchives(this.staging, { platform, arch: target.arch })
|
||||
const pnpm = await runtimeNpmArchive('pnpm', packageManager.replace('pnpm@', ''))
|
||||
const inputs = { version, target: `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}`, office, pnpm }
|
||||
const identity = createHash('sha256').update(JSON.stringify({ ...inputs, primaryLock })).digest('hex')
|
||||
await writeFile(join(resources, 'downloads.json'), JSON.stringify({ ...inputs, identity }, undefined, 2) + '\n')
|
||||
await prepareOfficeSkillAssets(join(this.staging, 'node_modules/@deepseek-ai/dsh-skill-office/assets'), join(resources, 'office-skills'))
|
||||
}
|
||||
if (target.platform !== 'macos') return [product, ripgrep, office, resources]
|
||||
if (target.platform !== 'macos') return [product, ripgrep, resources]
|
||||
const spawnHelper = `${product}-spawn-helper`
|
||||
const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
|
||||
if (this.cli.dryRun) {
|
||||
@@ -399,7 +410,7 @@ class SingleExeBuild {
|
||||
await copyFile(source, spawnHelper)
|
||||
await chmod(spawnHelper, 0o755)
|
||||
}
|
||||
return [product, ripgrep, spawnHelper, office, resources]
|
||||
return [product, ripgrep, spawnHelper, resources]
|
||||
}
|
||||
|
||||
/** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
|
||||
@@ -446,6 +457,17 @@ class SingleExeBuild {
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
)
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
const selected = `${platform}-${target.arch}`
|
||||
const prebuilds = join(this.staging, 'node_modules/node-pty/prebuilds')
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] keep only node-pty prebuilds ${selected}`)
|
||||
else {
|
||||
const source = join(packageDirectory, 'prebuilds', selected)
|
||||
if (existsSync(source)) await cp(source, join(prebuilds, selected), { recursive: true })
|
||||
for (const entry of await readdir(prebuilds, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name !== selected) await rm(join(prebuilds, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
if (target.platform === 'win') {
|
||||
if (target.arch !== 'x64') {
|
||||
throw new Error('build-exe-for-python-sdk: Windows supports x64 only.')
|
||||
|
||||
@@ -56,11 +56,6 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]:
|
||||
return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names
|
||||
|
||||
|
||||
def office_sidecar_name(executable_name: str) -> str:
|
||||
"""Return the complete Office dependency directory for one executable."""
|
||||
return f"{executable_name.removesuffix('.exe')}-office"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
|
||||
@@ -229,54 +224,18 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
|
||||
source_directory = executable.parent
|
||||
for filename in runtime_filenames(executable_name):
|
||||
shutil.copy2(source_directory / filename, runtime_dir / filename)
|
||||
office = office_sidecar_name(executable_name)
|
||||
shutil.copytree(source_directory / office, runtime_dir / office)
|
||||
resources = executable_name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
|
||||
shutil.copytree(source_directory / resources, runtime_dir / resources)
|
||||
|
||||
|
||||
def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None:
|
||||
"""Check packaged engine assets and native helper executable permissions."""
|
||||
names = set(archive.namelist())
|
||||
adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json"
|
||||
if adapter not in names:
|
||||
raise RuntimeError("Office dependency is missing: libreoffice-kit")
|
||||
engines = f"{office_modules}/@deepseek-ai"
|
||||
target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag)
|
||||
native_target = target.replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(archive.read(adapter)).get("optionalDependencies", {})
|
||||
selected = native_target if f"@deepseek-ai/libreoffice-kit-{native_target}" in declared else "wasm"
|
||||
for required in (f"libreoffice-kit-{selected}/prebuilds.json",):
|
||||
if f"{engines}/{required}" not in names:
|
||||
raise RuntimeError(f"Office dependency is missing: {required}")
|
||||
manifests = (
|
||||
name for name in names
|
||||
if name.startswith(f"{engines}/libreoffice-kit-")
|
||||
and name.endswith("/prebuilds.json")
|
||||
and name.count("/") == engines.count("/") + 2
|
||||
)
|
||||
for manifest_path in manifests:
|
||||
if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json":
|
||||
raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}")
|
||||
manifest = json.loads(archive.read(manifest_path))
|
||||
engine = manifest["engine"]
|
||||
native = engine["kind"] == "native"
|
||||
fields = ("executable",) if native else ("loader", "wasm", "data", "metadata")
|
||||
for field in fields:
|
||||
asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}"
|
||||
if asset not in names:
|
||||
raise RuntimeError(f"Office engine asset is missing: {asset}")
|
||||
mode = archive.getinfo(asset).external_attr >> 16
|
||||
if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"Office helper lost its executable bit: {asset}")
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
wheel: Path,
|
||||
package: str,
|
||||
version: str,
|
||||
platform: tuple[str, str] | None,
|
||||
) -> None:
|
||||
if wheel.stat().st_size > 100_000_000:
|
||||
raise RuntimeError(f"{wheel} exceeds the PyPI single-file limit of 100000000 bytes")
|
||||
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
|
||||
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
|
||||
dist_info = f"{expected_distribution.replace('-', '_')}-{version}.dist-info"
|
||||
@@ -308,15 +267,12 @@ def verify_wheel(
|
||||
]
|
||||
if package == "runtime":
|
||||
assert platform is not None
|
||||
office = office_sidecar_name(platform[1])
|
||||
resources = platform[1].removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
|
||||
expected_files = sorted((*runtime_filenames(platform[1]), office, resources))
|
||||
expected_files = sorted((*runtime_filenames(platform[1]), resources))
|
||||
found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload})
|
||||
if found_files != expected_files:
|
||||
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
|
||||
office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules"
|
||||
verify_office_payload(archive, office_modules, platform[0])
|
||||
validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_resources"]
|
||||
validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_downloads"]
|
||||
target = next(name for name, value in PLATFORMS.items() if value == platform)
|
||||
validate(zipfile.Path(archive, f"deepseek_harness_runtime/runtime/{resources}/"), target)
|
||||
for runtime_file in runtime_payload:
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function apply(ctx, config) {
|
||||
if (json === undefined) throw new Error('Office skill did not supply CLI paths')
|
||||
const { libreofficeKit: { node, cli } } = JSON.parse(json)
|
||||
const options = { env: { ...process.env, PATH: '' }, timeout: 120_000 }
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options)
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities', '--json'], options)
|
||||
await promisify(execFile)(node, [cli, 'convert', '--input', config.input, '--output', config.output + '.cli.pdf'], options)
|
||||
const pdf = await readFile(config.output + '.cli.pdf')
|
||||
if (pdf.subarray(0, 5).toString() !== '%PDF-') throw new Error('Skill CLI did not create a PDF')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Prepare pinned, relocatable script interpreters without installing into the build host. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { cp } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -10,30 +10,97 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import extractZip from 'extract-zip'
|
||||
import { x as extractTar } from 'tar'
|
||||
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts'
|
||||
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
import lock from './lock.json' with { type: 'json' }
|
||||
|
||||
/**
|
||||
* Download or reuse an archive only when its bytes match the release lock.
|
||||
* @param url - Locked archive URL.
|
||||
* @param sha256 - Expected SHA-256 digest.
|
||||
* @param sha256 - Expected hexadecimal digest for the selected algorithm.
|
||||
* @param cache - Download cache directory.
|
||||
* @param algorithm - Digest algorithm; npm archives use SHA-512.
|
||||
* @returns Verified local archive path.
|
||||
*/
|
||||
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise<string> {
|
||||
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string, algorithm: 'sha256' | 'sha512' = 'sha256'): Promise<string> {
|
||||
mkdirSync(cache, { recursive: true })
|
||||
const destination = join(cache, sha256)
|
||||
let bytes: Buffer
|
||||
try { bytes = readFileSync(destination) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const response = await fetch(url)
|
||||
const timeout = Number(process.env.DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS ?? '300000')
|
||||
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) throw new Error('DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS must be an integer from 1 to 2147483647')
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(timeout) })
|
||||
if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`)
|
||||
bytes = Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`)
|
||||
writeFileSync(destination, bytes)
|
||||
if (createHash(algorithm).update(bytes).digest('hex') !== sha256) {
|
||||
rmSync(destination, { force: true })
|
||||
throw new Error(`primary runtime download: checksum mismatch for ${url}`)
|
||||
}
|
||||
const temporary = join(cache, `.${randomUUID()}`)
|
||||
try {
|
||||
writeFileSync(temporary, bytes)
|
||||
renameSync(temporary, destination)
|
||||
} finally {
|
||||
rmSync(temporary, { force: true })
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
/** Pinned npm tarball, without dependency resolution or lifecycle scripts. */
|
||||
export interface RuntimeNpmArchive {
|
||||
/** Exact published package name. */
|
||||
readonly name: string
|
||||
/** Exact published version. */
|
||||
readonly version: string
|
||||
/** Registry tarball URL. */
|
||||
readonly url: string
|
||||
/** npm SHA-512 integrity. */
|
||||
readonly integrity: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and unpack one locked npm package without running its scripts.
|
||||
* @param artifact - Published tarball identity and integrity.
|
||||
* @param destination - Empty package directory.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after package identity validation.
|
||||
*/
|
||||
export async function downloadRuntimeNpmPackage(artifact: RuntimeNpmArchive, destination: string, cache: string): Promise<void> {
|
||||
if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(artifact.integrity)) throw new Error(`runtime download: invalid integrity for ${artifact.name}`)
|
||||
const checksum = Buffer.from(artifact.integrity.slice(7), 'base64').toString('hex')
|
||||
const archive = await downloadPrimaryRuntimeAsset(artifact.url, checksum, cache, 'sha512')
|
||||
mkdirSync(destination, { recursive: true })
|
||||
await extractTar({ file: archive, cwd: destination, strip: 1 })
|
||||
const installed = JSON.parse(readFileSync(join(destination, 'package.json'), 'utf8')) as { name?: string; version?: string }
|
||||
if (installed.name !== artifact.name || installed.version !== artifact.version) throw new Error(`runtime download: package identity mismatch for ${artifact.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the locked standalone Node independently of Python and pnpm.
|
||||
* @param target - Target interpreter platform.
|
||||
* @param destination - Directory receiving bin/node and LICENSE.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after extracting the interpreter.
|
||||
*/
|
||||
export async function downloadNodeRuntime(target: PrimaryRuntimeTarget, destination: string, cache: string): Promise<void> {
|
||||
const artifact = lock.targets[target]
|
||||
const filename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
|
||||
const archive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${filename}`, artifact.nodeSha256, cache)
|
||||
const staging = mkdtempSync(join(tmpdir(), 'dsh-node-'))
|
||||
try {
|
||||
if (target === 'win-x64') await extractZip(archive, { dir: staging })
|
||||
else await extractTar({ file: archive, cwd: staging })
|
||||
const source = join(staging, filename.replace(/\.(?:zip|tar\.gz)$/u, ''))
|
||||
mkdirSync(join(destination, 'bin'), { recursive: true })
|
||||
cpSync(join(source, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
|
||||
join(destination, 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
|
||||
cpSync(join(source, 'LICENSE'), join(destination, 'LICENSE'))
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise<string> {
|
||||
const artifact = lock.targets[target]
|
||||
const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz`
|
||||
@@ -91,7 +158,7 @@ export async function prepareOfficeSkillAssets(source: string, destination: stri
|
||||
/** A locked interpreter and wheel target. */
|
||||
export type PrimaryRuntimeTarget = keyof typeof lock.targets
|
||||
|
||||
/** Build-only inputs shared by Desktop and SDK carriers. */
|
||||
/** Resource preparation inputs shared by builds and explicit SDK downloads. */
|
||||
export interface PreparePrimaryRuntimeOptions {
|
||||
/** Target whose archives and wheels are downloaded. */
|
||||
readonly target: PrimaryRuntimeTarget
|
||||
@@ -101,6 +168,10 @@ export interface PreparePrimaryRuntimeOptions {
|
||||
readonly cache: string
|
||||
/** Carrier release recorded in the legacy desktopVersion manifest field. */
|
||||
readonly version: string
|
||||
/** Locked npm pnpm tarball for installed carriers; build hosts otherwise copy their installed pnpm. */
|
||||
readonly pnpmArchive?: RuntimeNpmArchive
|
||||
/** Ordinary filesystem Office assets; build hosts otherwise resolve the workspace package. */
|
||||
readonly skillSource?: string
|
||||
/** Omit Node.js and pnpm for carriers providing only Python. */
|
||||
readonly pythonOnly?: boolean
|
||||
}
|
||||
@@ -113,7 +184,6 @@ export interface PreparePrimaryRuntimeOptions {
|
||||
export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise<void> {
|
||||
const { target } = options
|
||||
const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) }
|
||||
const artifact = lock.targets[target]
|
||||
mkdirSync(paths.runtime, { recursive: true })
|
||||
mkdirSync(paths.downloads, { recursive: true })
|
||||
const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-'))
|
||||
@@ -123,23 +193,18 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
mkdirSync(dependencies, { recursive: true })
|
||||
let pnpmVersion: string | undefined
|
||||
if (!options.pythonOnly) {
|
||||
const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
|
||||
const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads)
|
||||
const unpackedNode = join(staging, 'node')
|
||||
mkdirSync(unpackedNode)
|
||||
if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode })
|
||||
else await extractTar({ file: nodeArchive, cwd: unpackedNode })
|
||||
const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, ''))
|
||||
mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true })
|
||||
mkdirSync(join(dependencies, 'node', 'node_modules'))
|
||||
writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
|
||||
cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
|
||||
join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
|
||||
cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE'))
|
||||
const require = createRequire(import.meta.url)
|
||||
const pnpmManifest = require.resolve('pnpm')
|
||||
pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version
|
||||
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
|
||||
const node = join(dependencies, 'node')
|
||||
await downloadNodeRuntime(target, node, paths.downloads)
|
||||
mkdirSync(join(node, 'node_modules'))
|
||||
writeFileSync(join(node, 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
|
||||
if (options.pnpmArchive !== undefined) {
|
||||
pnpmVersion = options.pnpmArchive.version
|
||||
await downloadRuntimeNpmPackage(options.pnpmArchive, join(dependencies, 'pnpm'), paths.downloads)
|
||||
} else {
|
||||
const pnpmManifest = createRequire(import.meta.url).resolve('pnpm')
|
||||
pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version
|
||||
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
|
||||
}
|
||||
}
|
||||
await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies })
|
||||
const manifest: PrimaryRuntimeManifest = {
|
||||
@@ -152,7 +217,7 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
pythonPackages: lock.pythonPackages,
|
||||
}
|
||||
const entries = workspaceDependencyPaths(output, manifest)
|
||||
for (const wheel of [...artifact.wheels, ...lock.wheels]) {
|
||||
for (const wheel of [...lock.targets[target].wheels, ...lock.wheels]) {
|
||||
await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
|
||||
}
|
||||
writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
|
||||
@@ -162,8 +227,8 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
|
||||
const assets = options.skillSource ?? join(dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets')
|
||||
await prepareOfficeSkillAssets(assets,
|
||||
join(paths.runtime, 'office-skills'))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Explicit resource acquisition through the Python carrier's private dispatcher. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resource-download-'))
|
||||
const bootstrap = join(root, 'node', 'runtime-bootstrap.mjs')
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform
|
||||
const resources = join(root, `${platform}-${process.arch}`)
|
||||
const cache = join(root, 'cache')
|
||||
async function file(path: string, content: string) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
await file(join(resources, 'downloads.json'), JSON.stringify({ identity: 'a'.repeat(64), office: [], pnpm: {} }))
|
||||
await mkdir(dirname(bootstrap), { recursive: true })
|
||||
await copyFile(new URL('../python/sdk-runtime/runtime-bootstrap.mjs', import.meta.url), bootstrap)
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/package.json'), '{"type":"module","exports":"./index.mjs"}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/index.mjs'), 'export async function installProxyFromEnvironment(env) { if (typeof env.get !== "function") throw new Error("missing environment lookup"); return async () => {} }')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/package.json'), '{"type":"module","exports":{"./runner":"./runner.mjs"}}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/runner.mjs'), '')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh/package.json'), '{"type":"module"}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh/lib/bin.js'), 'export async function runCli() { console.log(JSON.stringify({ primary: process.env.DSH_BUNDLED_PRIMARY_RUNTIME, office: process.env.DSH_BUNDLED_OFFICE_CLI })) }')
|
||||
await file(join(root, 'node/primary-runtime.mjs'), `
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
export async function downloadNodeRuntime(target, destination) {
|
||||
await mkdir(join(destination, 'bin'), { recursive: true });
|
||||
await writeFile(join(destination, 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), 'node');
|
||||
}
|
||||
export async function preparePrimaryRuntime({ output }) {
|
||||
await mkdir(join(output, 'primary-runtime'), { recursive: true });
|
||||
await writeFile(join(output, 'primary-runtime/runtime.json'), '{}');
|
||||
}
|
||||
`)
|
||||
await file(join(root, 'node/office-sidecar.mjs'), `
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
export async function downloadOfficeSidecar(packages, destination) {
|
||||
const directory = join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/lib');
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, 'cli.js'), 'cli');
|
||||
if (process.env.DSH_FIXTURE_FAIL) throw new Error('fixture download failed');
|
||||
if (process.env.DSH_FIXTURE_BARRIER) {
|
||||
process.stderr.write('ready\\n');
|
||||
await new Promise(resolve => process.stdin.once('data', resolve));
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
`)
|
||||
function run(flag?: string, extra: NodeJS.ProcessEnv = {}) {
|
||||
const child = spawn(process.execPath, [bootstrap, ...flag === undefined ? [] : [flag]], {
|
||||
env: { ...process.env, DSH_RESOURCE_CACHE: cache, DSH_BUNDLED_PRIMARY_RUNTIME: undefined,
|
||||
DSH_OFFICE_SIDECAR: undefined, DSH_BUNDLED_OFFICE_CLI: undefined, ...extra },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = '', stderr = ''
|
||||
child.stdout.on('data', (data) => { stdout += String(data) })
|
||||
let reportReady: (() => void) | undefined
|
||||
let failReady: ((error: Error) => void) | undefined
|
||||
const ready = extra.DSH_FIXTURE_BARRIER === undefined ? Promise.resolve()
|
||||
: new Promise<void>((resolve, reject) => { reportReady = resolve; failReady = reject })
|
||||
child.stderr.on('data', (data) => { stderr += String(data); if (stderr.includes('ready\n')) reportReady?.() })
|
||||
const done = new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => { failReady?.(new Error(`download exited before readiness: ${stderr}`)); resolve({ code, stdout, stderr }) })
|
||||
})
|
||||
return { child, ready, done }
|
||||
}
|
||||
return { root, cache, run }
|
||||
}
|
||||
|
||||
it('starts without downloading, independently downloads either resource, and reuses completed caches', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
try {
|
||||
expect(await run().done).toMatchObject({ code: 0, stdout: '{}\n' })
|
||||
await expect(readdir(cache)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const primary = await run('--download-primary-runtime').done
|
||||
expect(primary.code, primary.stderr).toBe(0)
|
||||
expect(JSON.parse(primary.stdout)).toBe(join(cache, 'a'.repeat(64), 'primary', 'primary-runtime'))
|
||||
await expect(readdir(join(cache, 'a'.repeat(64), 'office'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const office = await run('--download-office').done
|
||||
expect(office.code, office.stderr).toBe(0)
|
||||
const location = JSON.parse(office.stdout) as string
|
||||
expect(await readFile(join(location, 'node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js'), 'utf8')).toBe('cli')
|
||||
expect(await run('--download-office', { DSH_FIXTURE_FAIL: '1' }).done).toMatchObject({ code: 0, stdout: office.stdout })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('cleans failed staging and allows an explicit retry', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
try {
|
||||
const failed = await run('--download-office', { DSH_FIXTURE_FAIL: '1' }).done
|
||||
expect(failed.code).toBe(1)
|
||||
expect(failed.stderr).toContain('fixture download failed')
|
||||
expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual([])
|
||||
expect((await run('--download-office').done).code).toBe(0)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('publishes one complete cache when two processes download concurrently', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
const jobs = [run('--download-office', { DSH_FIXTURE_BARRIER: '1' }), run('--download-office', { DSH_FIXTURE_BARRIER: '1' })]
|
||||
try {
|
||||
await Promise.all(jobs.map(job => job.ready))
|
||||
for (const job of jobs) job.child.stdin.end('continue')
|
||||
const results = await Promise.all(jobs.map(job => job.done))
|
||||
for (const result of results) expect(result.code, result.stderr).toBe(0)
|
||||
expect(results[0]!.stdout).toBe(results[1]!.stdout)
|
||||
expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual(['office'])
|
||||
} finally {
|
||||
for (const job of jobs) if (job.child.exitCode === null && job.child.signalCode === null) job.child.kill()
|
||||
await Promise.allSettled(jobs.map(job => job.done))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -886,8 +886,9 @@ def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool)
|
||||
"""Query the bundled Python and switch skills without replacing that environment."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
resources = executable.with_name(executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe"))
|
||||
manifest = json.loads((resources / "primary-runtime/runtime.json").read_text())
|
||||
primary = Path(json.loads(subprocess.check_output([str(executable), "--download-primary-runtime"], text=True)))
|
||||
resources = primary.parent
|
||||
manifest = json.loads((primary / "runtime.json").read_text())
|
||||
for mode in ("default", "replacement", "disabled"):
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
@@ -958,7 +959,9 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
office = root / f"{stem}-office"
|
||||
cache_environment = {**os.environ, "DSH_RESOURCE_CACHE": str(root / "cache")}
|
||||
office = Path(json.loads(subprocess.check_output([str(relocated), "--download-office"], text=True, env=cache_environment)))
|
||||
assert not (office.parent / "primary").exists(), "Office download unexpectedly prepared the authoring environment"
|
||||
adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json"
|
||||
native = stem.removeprefix("deepseek-harness-sdk-runtime-").replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {})
|
||||
@@ -997,7 +1000,7 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
patches=(str(patch),),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url="http://127.0.0.1:9",
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "DSH_RESOURCE_CACHE": str(root / "cache")},
|
||||
# The startup plugin awaits a converter with a 120-second deadline before JSON-RPC is ready.
|
||||
initialize_timeout_seconds=180,
|
||||
request_timeout_seconds=180,
|
||||
@@ -1007,10 +1010,32 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
if result["backend"] != expected_backend:
|
||||
raise AssertionError(f"Office conversion did not use {expected_backend}: {result}")
|
||||
if not result["moduleUrl"].startswith(office.as_uri() + "/"):
|
||||
raise AssertionError(f"Office module was not loaded from the relocated wheel: {result}")
|
||||
raise AssertionError(f"Office module was not loaded from the downloaded sidecar: {result}")
|
||||
pdf = output.read_bytes()
|
||||
if len(pdf) < 100 or not pdf.startswith(b"%PDF-"):
|
||||
raise AssertionError(f"Office conversion produced an invalid PDF at {output}")
|
||||
workbook = root / "input.xlsx"
|
||||
with zipfile.ZipFile(workbook, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
||||
archive.writestr("xl/workbook.xml", '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>')
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>')
|
||||
archive.writestr("xl/worksheets/sheet1.xml", '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1"><f>1+2</f><v>0</v></c></row></sheetData></worksheet>')
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import sys, zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
from deepseek_harness.office import convert, render_images, recalculate
|
||||
root = Path(sys.argv[1])
|
||||
assert convert(root / 'document.docx', root / 'python.pdf').backend == sys.argv[2]
|
||||
rendered = render_images(root / 'python.pdf', root / 'images', pages=[1])
|
||||
assert rendered.images and rendered.images[0].path.read_bytes().startswith(b'\\x89PNG')
|
||||
recalculate(root / 'input.xlsx', root / 'calculated.xlsx')
|
||||
with zipfile.ZipFile(root / 'calculated.xlsx') as archive:
|
||||
sheet = ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml'))
|
||||
assert sheet.find('.//{*}c[@r="A1"]/{*}v').text == '3'
|
||||
""", str(root), expected_backend], env={**cache_environment, "DSH_OFFICE_SIDECAR": str(office)}, check=True, timeout=180)
|
||||
assert not (office.parent / "primary").exists(), "Python Office API unexpectedly downloaded authoring resources"
|
||||
print(f"smoke-python-runtime: relocated Office {result['backend']} DOCX produced {len(pdf)} PDF bytes")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user