Merge pull request #5557 from deepseek-harness/fix/prune-python-runtime-tests

Prune NumPy/pandas upstream tests from bundled Python runtimes
This commit is contained in:
07akioni authored and GitHub committed 2026-10-09 16:48:52 +08:00
commit 65b8b7e853
15 files changed
+220 -7

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: 0f9b6c1d6761d108
zh: 77127063f244c361
en: 2b049b4f104be1c2
zh: 1e8f3a9f7eb15e2b
/deepseek-harness-desktop/bundled-command-runtime:
en: 09f0b75af9f0ed14
zh: 60bbc2e67d5e87a8
+2
View File
@@ -72,6 +72,8 @@ The private Desktop `runtime/bin` directory is added only to package-installatio
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
The shared Desktop/SDK runtime builder omits directories named `tests` beneath NumPy and pandas before copying or signing the payload, and removes their rows from each distribution’s `RECORD`. It retains `numpy.testing`, `pandas.testing`, `pandas._testing`, native extensions and licenses. Complete upstream suites are unavailable; native checks verify the retained testing APIs, absence of test directories and existence of recorded files.
| Decision | Why | Direct consequence |
|---|---|---|
| Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
+2
View File
@@ -74,6 +74,8 @@ Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PT
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
Desktop/SDK 共享运行时构建器在复制或签名产物前,裁剪 NumPy 和 pandas 下名为 `tests` 的目录,并移除各分发包 `RECORD` 中对应的记录。它保留 `numpy.testing`、`pandas.testing`、`pandas._testing`、原生扩展和许可证。完整上游测试套件不可用;本机检查验证保留的 testing API、测试目录已移除,以及清单所列文件存在。
| 决策 | 原因 | 直接结果 |
|---|---|---|
| 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
@@ -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.0-rc.2/bundled-python-tests/guide.md
/:
en: 985656162a55b14a
zh: c32bda15938879e5
/numpy-and-pandas-upstream-tests-require-a-separate-environment:
en: ec0ae29022f8ce8c
zh: f5e49682b842544d
/numpy-and-pandas-upstream-tests-require-a-separate-environment/change:
en: 41410ee875be7975
zh: b052e7b3c8fb096b
/numpy-and-pandas-upstream-tests-require-a-separate-environment/migration:
en: 00c4b10f8df805e7
zh: 06ef0fbda56c9acf
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "Bundled NumPy and pandas omit their upstream test suites."
---
# NumPy and pandas upstream tests require a separate environment
English | [中文](guide.zh.md)
## Change
Desktop and Python SDK runtime distributions previously included NumPy and pandas test directories. Bundled distributions now omit those directories, so scripts importing their test modules or running their complete upstream suites need a separate installation. Data processing, Office authoring, `numpy.testing`, `pandas.testing`, and `pandas._testing` remain available.
## Migration
1. If your scripts import `numpy.*.tests` or `pandas.tests`, install the original NumPy and pandas distributions and their upstream testing dependencies in a separate Python environment. Keep that environment outside the managed Harness runtime directory, which upgrades replace.
2. Run the affected scripts or upstream suites with that environment's interpreter. Scripts that use only library functionality or retained testing helpers require no changes.
3. Confirm that the previously failing test-module imports and affected scripts succeed with the separate interpreter. The bundled versions are recorded in the [runtime lock](../../../../scripts/primary-runtime/lock.json).
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "内置 NumPy 和 pandas 不包含其上游测试套件。"
---
# NumPy 和 pandas 上游测试需要独立环境
[English](guide.md) | 中文
## 变更
Desktop 和 Python SDK 运行时分发包此前包含 NumPy 和 pandas 测试目录。内置分发包现在裁剪这些目录,因此导入其测试模块或运行其完整上游测试套件的脚本需要独立安装环境。数据处理、Office 创作、`numpy.testing`、`pandas.testing` 和 `pandas._testing` 仍然可用。
## 迁移
1. 如果脚本导入 `numpy.*.tests` 或 `pandas.tests`,请在独立 Python 环境中安装原始 NumPy 和 pandas 分发包及其上游测试依赖。请将该环境放在 Harness 托管运行时目录之外,升级会替换托管目录。
2. 使用该环境的解释器运行受影响的脚本或上游测试套件。仅使用库功能或保留的 testing 辅助 API 的脚本无需修改。
3. 确认先前失败的测试模块导入及相关脚本均可在独立解释器中成功运行。随包版本记录在[运行时锁文件](../../../../scripts/primary-runtime/lock.json)中。
+2
View File
@@ -238,6 +238,7 @@
"@types/js-yaml": "^4.0.9",
"@types/jsdom": "^28.0.3",
"@types/mdast": "^4.0.4",
"@types/papaparse": "^5.5.2",
"@types/node": "^22.20.1",
"@types/semver": "^7.8.0",
"@types/spdx-expression-parse": "^4.0.0",
@@ -262,6 +263,7 @@
"micromark-extension-gfm": "^3.0.0",
"oxlint": "1.81.0",
"oxlint-tsgolint": "7.0.2001",
"papaparse": "5.5.3",
"pnpm": "11.28.5",
"publint": "^0.3.24",
"semver": "^7.8.5",
+13
View File
@@ -118,6 +118,9 @@ importers:
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
'@types/spdx-expression-parse':
specifier: ^4.0.0
version: 4.0.0
@@ -181,6 +184,9 @@ importers:
oxlint-tsgolint:
specifier: 7.0.2001
version: 7.0.2001
papaparse:
specifier: 5.5.3
version: 5.5.3
pnpm:
specifier: 11.28.5
version: 11.28.5
@@ -15635,6 +15641,10 @@ importers:
packages:
[email protected]:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
'@agentclientprotocol/[email protected]':
resolution: {integrity: sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==}
peerDependencies:
@@ -23217,6 +23227,9 @@ packages:
snapshots:
[email protected]: {}
'@agentclientprotocol/[email protected]([email protected])':
dependencies:
zod: 4.4.3
+2 -2
View File
@@ -6,8 +6,8 @@
en: 1ea1d8e6d2c14b02
zh: 8e0b9903158667a1
/deepseek-harness-runtime-bin/installed-commands-and-artifacts:
en: 89c97974ad5e97a6
zh: af99634672c80994
en: b35529d69ef29f15
zh: 4e3d9e42955cd010
/deepseek-harness-runtime-bin/python-module-api:
en: 78a200e328d96637
zh: f3b7a0b69b7a39e0
+1 -1
View File
@@ -21,7 +21,7 @@ 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 authoring download validates its target, interpreters, library directory and skills before returning. The existing local `installPrimaryRuntime()` operation still copies an already available payload; it does not download.
`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 authoring download validates its target, interpreters, library directory and skills before returning. The existing local `installPrimaryRuntime()` operation still copies an already available payload; it does not download. The shared builder applies the [NumPy/pandas test-file exclusions](../../apps/desktop/README.md#bundled-workspace-dependencies).
`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. Release versions have separate cache directories; unused versions require manual removal. Downloads use the locked upstream URLs without a registry or mirror override.
+1 -1
View File
@@ -21,7 +21,7 @@ office = download_office()
primary_runtime = download_primary_runtime()
```
`download_office()` 下载锁定版本的 npm Kit、目标引擎及完整依赖树,并准备运行 Kit CLI 的独立 Node。`download_primary_runtime()` 独立下载 CPython、锁定的 Python 库、Node、pnpm 和 Office skills。两者返回绝对资源目录,并复用完整缓存。创作下载会在返回前校验目标平台、解释器、库目录与 skills。现有本地 `installPrimaryRuntime()` 操作仍只复制已有载荷,不执行下载。
`download_office()` 下载锁定版本的 npm Kit、目标引擎及完整依赖树,并准备运行 Kit CLI 的独立 Node。`download_primary_runtime()` 独立下载 CPython、锁定的 Python 库、Node、pnpm 和 Office skills。两者返回绝对资源目录,并复用完整缓存。创作下载会在返回前校验目标平台、解释器、库目录与 skills。现有本地 `installPrimaryRuntime()` 操作仍只复制已有载荷,不执行下载。 共享构建器采用 [NumPy/pandas 测试文件裁剪规则](../../apps/desktop/README.zh.md#bundled-workspace-dependencies)。
`DSH_RESOURCE_CACHE` 指定绝对缓存根目录,默认为 `~/.cache/deepseek-harness/resources`。资源身份包含发布版本锁定的输入。下载会校验归档哈希和包身份、保留执行权限,并原子发布完整目录。下载失败后可重试。`DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` 限制每个归档的下载耗时,包含响应内容;默认值为 `300000`,接受 `1` 到 `2147483647` 的整数。下载遵循 Harness 代理环境策略。已安装的 wheel 文件不会被修改。不同发布版本使用独立缓存目录;不再使用的版本需手动删除。下载使用锁定的上游 URL,不提供 registry 或镜像覆盖。
+3 -1
View File
@@ -12,6 +12,7 @@ import extractZip from 'extract-zip'
import { x as extractTar } from 'tar'
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
import lock from './lock.json' with { type: 'json' }
import { prunePrimaryRuntimePythonTests } from './prune-python-tests.ts'
/**
* Download or reuse an archive only when its bytes match the release lock.
@@ -124,7 +125,7 @@ export function primaryRuntimePayloadDigest(
// Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order.
// Bump format when extraction or assembly changes payload bytes without changing locked inputs.
return createHash('sha256').update(JSON.stringify({
format: 4, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion,
format: 6, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion,
artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion,
})).digest('hex')
}
@@ -223,6 +224,7 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
for (const wheel of [...lock.targets[target].wheels, ...lock.wheels]) {
await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
}
prunePrimaryRuntimePythonTests(entries.pythonPackages)
writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
const destination = join(paths.runtime, 'primary-runtime')
rmSync(destination, { recursive: true, force: true })
@@ -0,0 +1,85 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { zipSync } from 'fflate'
import { expect, it } from 'vitest'
import lock from './lock.json' with { type: 'json' }
import { unpackPrimaryRuntimeWheel } from './prepare.ts'
import { prunePrimaryRuntimePythonTests } from './prune-python-tests.ts'
const records = {
numpy: `numpy-${lock.pythonPackages.numpy}.dist-info/RECORD`,
pandas: `pandas-${lock.pythonPackages.pandas}.dist-info/RECORD`,
}
it('removes only NumPy/pandas test directories and keeps retained wheel files and RECORD hashes', async () => {
const root = await mkdtemp(join(tmpdir(), 'primary-python-pruning-'))
try {
const removed = [
'numpy/tests/test_numpy.py', 'numpy/_core/tests/test_core.py', 'numpy/_core/tests/data/values,small.csv',
'numpy/testing/tests/test_utils.py', 'pandas/tests/io/test_excel.py', 'pandas/tests/io/data/workbook.xlsx',
]
const retained = [
'numpy/__init__.py', 'numpy/_core/_multiarray_tests.so', 'numpy/_core/_multiarray_tests.pyd',
'numpy/testing/__init__.py', 'numpy/_core/tests.py', 'numpy/data/values,small.csv',
'pandas/__init__.py', 'pandas/testing.py', 'pandas/_testing/__init__.py', 'pandas/_libs/testing.pyd',
`numpy-${lock.pythonPackages.numpy}.dist-info/licenses/LICENSE.txt`,
`pandas-${lock.pythonPackages.pandas}.dist-info/LICENSE`,
'other/tests/test_other.py', 'numpy_extra/tests/test_extra.py',
]
const files = Object.fromEntries([...removed, ...retained].map(path => [path, Buffer.from(`contents of ${path}\n`)]))
// Quoted paths exercise RECORD's CSV encoding even when a filename contains a comma.
const row = (path: string): string => `"${path}",sha256=${createHash('sha256').update(files[path]!).digest('base64url')},${files[path]!.length}\n`
for (const [name, record] of Object.entries(records)) {
const paths = [...removed, ...retained].filter(path => path.startsWith(`${name}/`) || path.startsWith(`${name}-`))
files[record] = Buffer.from(`${paths.map(row).join('')}${record},,\n`)
}
const archive = join(root, 'libraries.whl')
const sitePackages = join(root, 'site-packages')
const wheel = Buffer.from(zipSync(files))
await writeFile(archive, wheel)
await unpackPrimaryRuntimeWheel(archive, sitePackages)
await mkdir(join(sitePackages, 'numpy/empty/tests'), { recursive: true })
prunePrimaryRuntimePythonTests(sitePackages)
for (const path of [...removed, 'numpy/tests', 'numpy/empty/tests', 'pandas/tests']) {
await expect(readFile(join(sitePackages, path))).rejects.toMatchObject({ code: 'ENOENT' })
}
for (const path of retained) expect(await readFile(join(sitePackages, path))).toEqual(files[path])
for (const [name, record] of Object.entries(records)) {
const lines = (await readFile(join(sitePackages, record), 'utf8')).trimEnd().split('\n')
const expected = retained.filter(path => path.startsWith(`${name}/`) || path.startsWith(`${name}-`))
expect(lines).toHaveLength(expected.length + 1)
for (const path of expected) {
const expectedRow = row(path).trimEnd()
expect(lines).toContain(path.includes(',') ? expectedRow : expectedRow.replace(`"${path}"`, path))
}
expect(lines).toContain(`${record},,`)
}
expect(await readFile(archive)).toEqual(wheel)
const before = await Promise.all(Object.values(records).map(record => readFile(join(sitePackages, record))))
prunePrimaryRuntimePythonTests(sitePackages)
expect(await Promise.all(Object.values(records).map(record => readFile(join(sitePackages, record))))).toEqual(before)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it.each(['"unterminated,,\n', 'numpy/tests/test_numpy.py,\n', '', ',,\n'])(
'rejects malformed installed-file records before deleting that distribution\'s tests: %j', async (record) => {
const root = await mkdtemp(join(tmpdir(), 'primary-python-record-'))
try {
await mkdir(join(root, `numpy-${lock.pythonPackages.numpy}.dist-info`))
await writeFile(join(root, records.numpy), record)
await mkdir(join(root, 'numpy/tests'), { recursive: true })
const testFile = join(root, 'numpy/tests/test_numpy.py')
await writeFile(testFile, 'test fixture\n')
expect(() => { prunePrimaryRuntimePythonTests(root) }).toThrow('invalid wheel RECORD')
expect(await readFile(testFile, 'utf8')).toBe('test fixture\n')
} finally {
await rm(root, { recursive: true, force: true })
}
},
)
@@ -0,0 +1,41 @@
/** Remove upstream Python test suites from the staged primary runtime. */
import { readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import Papa from 'papaparse'
import lock from './lock.json' with { type: 'json' }
function removeTestDirectories(root: string, directory: string): string[] {
const removed: string[] = []
for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const path = `${directory}/${entry.name}`
if (entry.name === 'tests') {
rmSync(join(root, path), { recursive: true })
removed.push(path)
} else {
removed.push(...removeTestDirectories(root, path))
}
}
return removed
}
/**
* Prune NumPy/pandas tests and their RECORD rows, preserving testing helpers and native extensions.
* @param sitePackages - Staged site-packages directory containing the locked wheels.
* @throws If a locked distribution's RECORD is missing or malformed.
*/
export function prunePrimaryRuntimePythonTests(sitePackages: string): void {
for (const name of ['numpy', 'pandas'] as const) {
const record = join(sitePackages, `${name}-${lock.pythonPackages[name]}.dist-info`, 'RECORD')
const { data, errors } = Papa.parse<string[]>(readFileSync(record, 'utf8'), { delimiter: ',', skipEmptyLines: true })
if (errors.length > 0 || data.length === 0
|| !data.every((row): row is [string, string, string] => row.length === 3 && Boolean(row[0]))) {
throw new Error(`primary runtime: invalid wheel RECORD: ${record}`)
}
const removed = removeTestDirectories(sitePackages, name)
if (removed.length === 0) continue
const retained = data.filter(([path]) => !removed.some(directory => path.startsWith(`${directory}/`)))
writeFileSync(record, `${Papa.unparse(retained, { newline: '\n' })}\n`)
}
}
+14
View File
@@ -1,6 +1,8 @@
"""Exercise the relocated Office payload with its own isolated interpreter."""
import csv
import importlib.metadata
import io
import json
from pathlib import Path
import re
@@ -9,6 +11,8 @@ import sys
import tempfile
import numpy
# NumPy testing helpers require this native extension even without upstream test directories.
import numpy._core._multiarray_tests
import pandas
from docx import Document
from docx.shared import Inches as DocxInches
@@ -38,6 +42,16 @@ def main():
assert actual == expected, (name, actual, expected)
assert numpy.arange(4).sum() == 6
assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3
numpy.testing.assert_allclose(numpy.arange(4), [0, 1, 2, 3])
pandas.testing.assert_frame_equal(pandas.DataFrame({"n": [1, 2]}), pandas.DataFrame({"n": [1, 2]}))
for library in (numpy, pandas):
root = Path(library.__file__).parent
assert not any(path.is_dir() for path in root.rglob("tests")), root
distribution = importlib.metadata.distribution(library.__name__)
record = distribution.read_text("RECORD")
assert record, library.__name__
for file, _hash, _size in csv.reader(io.StringIO(record)):
assert distribution.locate_file(file).is_file(), file
with tempfile.TemporaryDirectory(prefix="dsh-office-smoke-") as directory:
root = Path(directory)