mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
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
@@ -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))
|
||||
Reference in new issue
Block a user