From ade150b71943882f16dffdcee5e9ef3dbdafb1be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:24 +0800 Subject: [PATCH] python: deepseek-harness SDK and runtime carrier packages --- .gitignore | 2 + docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- pytest.ini | 8 + python/README.i18n.yaml | 6 + python/README.md | 68 ++ python/README.zh.md | 68 ++ python/sdk-runtime/README.i18n.yaml | 6 + python/sdk-runtime/README.md | 27 + python/sdk-runtime/README.zh.md | 27 + python/sdk-runtime/pyproject.toml | 21 + .../src/deepseek_harness_runtime/__init__.py | 151 +++++ .../deepseek-harness-runtime.json | 1 + .../runtime/cordis.yml | 50 ++ python/sdk/README.i18n.yaml | 6 + python/sdk/README.md | 27 + python/sdk/README.zh.md | 21 + python/sdk/pyproject.toml | 31 + python/sdk/src/deepseek_harness/__init__.py | 17 + python/sdk/src/deepseek_harness/api.py | 223 +++++++ python/sdk/src/deepseek_harness/client.py | 481 ++++++++++++++ python/sdk/src/deepseek_harness/errors.py | 19 + python/sdk/src/deepseek_harness/models.py | 32 + python/sdk/tests/manual_sdk_agent_smoke.py | 127 ++++ python/sdk/tests/test_bundled_runtime.py | 120 ++++ python/sdk/tests/test_client.py | 592 ++++++++++++++++++ python/sdk/tests/test_runtime_resolution.py | 43 ++ python/sdk/uv.lock | 321 ++++++++++ scripts/translation-pairing.manifest.json | 8 +- scripts/verify-translation-pairing.ts | 4 +- 31 files changed, 2507 insertions(+), 8 deletions(-) create mode 100644 pytest.ini create mode 100644 python/README.i18n.yaml create mode 100644 python/README.md create mode 100644 python/README.zh.md create mode 100644 python/sdk-runtime/README.i18n.yaml create mode 100644 python/sdk-runtime/README.md create mode 100644 python/sdk-runtime/README.zh.md create mode 100644 python/sdk-runtime/pyproject.toml create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/__init__.py create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml create mode 100644 python/sdk/README.i18n.yaml create mode 100644 python/sdk/README.md create mode 100644 python/sdk/README.zh.md create mode 100644 python/sdk/pyproject.toml create mode 100644 python/sdk/src/deepseek_harness/__init__.py create mode 100644 python/sdk/src/deepseek_harness/api.py create mode 100644 python/sdk/src/deepseek_harness/client.py create mode 100644 python/sdk/src/deepseek_harness/errors.py create mode 100644 python/sdk/src/deepseek_harness/models.py create mode 100644 python/sdk/tests/manual_sdk_agent_smoke.py create mode 100644 python/sdk/tests/test_bundled_runtime.py create mode 100644 python/sdk/tests/test_client.py create mode 100644 python/sdk/tests/test_runtime_resolution.py create mode 100644 python/sdk/uv.lock diff --git a/.gitignore b/.gitignore index df36ca9214..6faccc8b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ tmp/ .DS_Store .idea mise.toml +python/**/__pycache__/ +python/**/.pytest_cache/ diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 7a2407ed36..2c81577523 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 6e2bbd27c3288037bafeb6cc71b801d56b956ab4 -README.zh.md: 04c99ae336cf1e96cbc185f0ccbd063ef8977944 +README.md: 81e958c8ae9552222f27b0f9674d4fa0ff2a481c +README.zh.md: 3764a9ade69f3ad99e36ccccbaed1d19d8bbb1f0 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 6e2bbd27c3..81e958c8ae 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -35,7 +35,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope, exclusions, and rollout -**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. +**Scope**: the root `README.md`, everything under `docs/**`, and everything under `python/**`. Package READMEs (`packages/**`) join the scope in a later batch. **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 04c99ae336..3764a9ade6 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -35,7 +35,7 @@ ## 范围、排除与推进 -**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 +**范围**:根 `README.md`、`docs/**` 下的全部内容,以及 `python/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..696fcc2bf3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# Root pytest config: pin collection to the Python SDK's real test tree. +# Without this, a bare `pytest` from the repo root recursively collects the +# whole worktree — including gitignored residue such as scratch checkouts or +# venvs — and same-basename test modules collide with an "import file +# mismatch" collection error. +[pytest] +testpaths = python/sdk/tests +norecursedirs = node_modules .git dist-exe diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml new file mode 100644 index 0000000000..29c3b03096 --- /dev/null +++ b/python/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 56199c1daaac4d55a29dae8852277f0ba77238b2 +README.zh.md: e34f3afd455926bd6b09d3451509594cee78ad88 diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..56199c1daa --- /dev/null +++ b/python/README.md @@ -0,0 +1,68 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python packages for driving DeepSeek Harness as a subprocess: a client SDK that spawns the `dsh-jsonrpc-agent` binary and talks newline-delimited JSON-RPC over stdio. The runtime carrier is the single-file executable produced by this repo; design, build, and acceptance details live in [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Packages + +| Directory | Dist / module | Role | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | Client SDK: the `DeepSeekHarness` high-level turns API and the lower-level `HarnessClient` JSON-RPC client | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Runtime carrier: locates the bundled runtime binaries and ships the default agent configuration | + +## Building the runtime executable + +The platform executables are build artifacts, not checked into git. From the repo root: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +Products land in `dist-exe/` (CI artifact shape) and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a build the SDK finds the executable with no further setup. Alternatively, download the platform artifact from the `build-exe-for-python-sdk` CI workflow (manual dispatch) and place it at that same path. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). + +## Validating the SDK against the executable + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the repo-root `.env`): + +```python +from deepseek_harness import DeepSeekHarness +print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## Running the SDK against the Node source (no executable) + +Two flavors, both for repo members: + +- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. +- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. + +## Distributing the Python packages + +Build one wheel per package; the runtime wheel embeds whatever executables are present under `runtime/` at build time (and always the default `cordis.yml`), and excludes `runtime/node/`: + +```sh +(cd python/sdk-runtime && uv build) # or: hatch build +(cd python/sdk && uv build) +pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +``` + +Once the binaries are in place (built or downloaded), installing the two wheels (or `pip install ./python/sdk-runtime ./python/sdk` straight from the directories) gives a working zero-config `DeepSeekHarness()`. Two pre-release caveats: the runtime wheel is currently tagged `py3-none-any` while embedding platform-specific binaries — build it with only the matching platform's exe in place (one wheel per platform), or drop all three exes in for a fat universal wheel (~500 MB); and versioning/publishing policy (`0.0.0-dev`, no registry) is deliberately unset until the first tagged release. + +## Zero-config semantics + +The runtime binary itself always requires an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as the first argv argument), has no built-in fallback, and boots only what the config lists. Zero-config is SDK wrapper behavior: when the caller uses no explicit channel, the client injects the runtime package's checked-in default configuration ([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml)) via `DSH_CORDIS_CONFIG`; any explicit channel wins and disables the injection. The full injection conditions live in the [sdk README](sdk/README.md); the default config's contents and the hard semantic in the [sdk-runtime README](sdk-runtime/README.md). + +## Test layout + +`test_client.py` is fully keyless (a Python fake runtime is the peer). `test_bundled_runtime.py` boots each bundled carrier and skips per carrier when its artifact is missing. `test_runtime_resolution.py` covers the carrier-resolution rules without spawning anything. diff --git a/python/README.zh.md b/python/README.zh.md new file mode 100644 index 0000000000..e34f3afd45 --- /dev/null +++ b/python/README.zh.md @@ -0,0 +1,68 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK spawn `dsh-jsonrpc-agent` 二进制,在 stdio 上以换行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 + +## 包 + +| 目录 | Dist / 模块 | 职责 | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | 客户端 SDK:高层回合 API `DeepSeekHarness` 与低层 JSON-RPC 客户端 `HarnessClient` | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 运行时载体:定位内置的运行时二进制,并携带默认的 agent(智能体)配置 | + +## 构建运行时可执行文件 + +各平台可执行文件是构建产物,不检入 git。在仓库根目录执行: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +产物落入 `dist-exe/`(CI 产物形态),并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),构建完成后 SDK 不需要额外设置就能找到可执行文件。也可以从 `build-exe-for-python-sdk` CI workflow(手动触发)下载对应平台的产物放到同一路径。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 + +## 用可执行文件验证 SDK + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +交互式验证(需要环境变量或仓库根 `.env` 中的 `DEEPSEEK_API_KEY`): + +```python +from deepseek_harness import DeepSeekHarness +print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## 对着 Node 源码运行 SDK(不用可执行文件) + +两种方式,均面向仓库成员: + +- **已构建的 node 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次跑构建脚本都会刷新,与 exe 打进 VFS 的是同一份依赖闭包,插件语义一致。不会被自动选中,也不进入分发物。 +- **未构建的源码(tsx)**——把客户端直接指向 bin 的 TypeScript 源码,用于编辑-运行循环与调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,另经 `cordis=...` 传入配置(或依赖默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 + +## 分发 Python 包 + +每个包各构建一个 wheel;runtime wheel 会内嵌构建时 `runtime/` 下存在的全部可执行文件(以及始终包含的默认 `cordis.yml`),并排除 `runtime/node/`: + +```sh +(cd python/sdk-runtime && uv build) # or: hatch build +(cd python/sdk && uv build) +pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +``` + +二进制就位(构建或下载)后,安装这两个 wheel(或直接 `pip install ./python/sdk-runtime ./python/sdk` 从目录安装),就得到零配置可用的 `DeepSeekHarness()`。两个 pre-release 注意事项:runtime wheel 目前的标签是 `py3-none-any`,内容却是平台相关的二进制——构建时只放匹配平台的 exe(每平台一个 wheel),或把三个 exe 全部放入做成通用胖 wheel(约 500 MB);版本与发布策略(`0.0.0-dev`、无 registry)刻意留待首个 tagged release 再定。 + +## 零配置语义 + +运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的东西。零配置是 SDK 包装层的行为:调用方没有走任何显式通道时,客户端把 runtime 包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即胜出并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 + +## 测试布局 + +`test_client.py` 完全无需 key(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某载体产物缺失时对应用例跳过。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml new file mode 100644 index 0000000000..d82d6cfa12 --- /dev/null +++ b/python/sdk-runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 5543b0ba00816c230dbe4e5bba11b7bf4b38096d +README.zh.md: 163b6d8e8373f0f770a5d95be688785907108b8a diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md new file mode 100644 index 0000000000..5543b0ba00 --- /dev/null +++ b/python/sdk-runtime/README.md @@ -0,0 +1,27 @@ +# DeepSeek Harness Runtime Wheel + +English | [中文](README.zh.md) + +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. + +## Runtime carriers + +Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: + +- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel/sdist distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. + +Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. + +Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow and place it in the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. + +## Resolution API + +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above). +- `bundled_default_config_path() -> Path` — the checked-in default config (see below). +- `bundled_package_dir() -> Path` — the installed package data root. + +## Zero-config design + +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` (the JSON-RPC serving entry, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash, each parameterized by the `DSH_*` env vars the SDK sets); when the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md new file mode 100644 index 0000000000..163b6d8e83 --- /dev/null +++ b/python/sdk-runtime/README.zh.md @@ -0,0 +1,27 @@ +# DeepSeek Harness 运行时 wheel + +[English](README.md) | 中文 + +Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 + +## 运行时载体 + +两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 gitignore: + +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel/sdist 分发物的载体。 +- **node(仅限开发)**——`runtime/node/` 下的完整 deploy 闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 + +两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 + +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物并放入 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 + +## 解析 API + +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;node 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 +- `bundled_package_dir() -> Path`——已安装包的数据根目录。 + +## 零配置设计 + +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;服务面(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它启动出的 agent 没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未走任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。零配置因此是 wrapper 里一次显式、可见的参数传递,不是运行时里的隐藏回退。 diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml new file mode 100644 index 0000000000..ed1e147494 --- /dev/null +++ b/python/sdk-runtime/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness-runtime-bin" +version = "0.0.0-dev" +description = "Pinned DeepSeek Harness runtime for the Python SDK" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } + +# Distributions carry the platform executables (build-injected, VCS-ignored — +# hence `artifacts`) and the checked-in runtime/cordis.yml; the dev-only node +# closure under runtime/node/ is explicitly excluded from wheel and sdist. +[tool.hatch.build] +artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] +exclude = ["src/deepseek_harness_runtime/runtime/node"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness_runtime"] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py new file mode 100644 index 0000000000..bd14ed0211 --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -0,0 +1,151 @@ +"""Locate the bundled DeepSeek Harness SDK runtime shipped with this package. + +Two runtime carriers coexist under ``runtime/``, both injected by the repo's +``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): + +- **exe (production)**: single-file executables named + ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in + {x64, arm64}); the target machine needs no 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-jsonrpc-agent/lib/bin.js`` on a + system Node >= 22.19. It is the current checkout's source build, never + selected automatically, and excluded from wheel/sdist distributions. + +``runtime/cordis.yml`` IS checked in: it is the default agent configuration +the client SDK injects via ``$DSH_CORDIS_CONFIG`` for zero-config runs — the +runtime itself always requires an explicit config and has no built-in +fallback. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import sys +from pathlib import Path + +PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" + +RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" + +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} + +_EXE_ACQUISITION_HINT = ( + "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " + "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` " + "CI workflow; place the file in this package's runtime/ directory. For local development " + "against a repo source build, explicitly select the dev-only node carrier with " + f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." +) + + +def bundled_package_dir() -> Path: + """Root directory of the installed runtime package data (the directory of this module).""" + root = Path(__file__).resolve().parent + metadata = root / PACKAGE_METADATA_FILENAME + if not metadata.is_file(): + raise FileNotFoundError(f"deepseek-harness-runtime-bin is missing {metadata}") + return root + + +def bundled_default_config_path() -> Path: + """Path of the checked-in default runtime configuration (``runtime/cordis.yml``). + + The client SDK injects this path via ``$DSH_CORDIS_CONFIG`` when the caller + supplies no config and the launch resolves to the bundled runtime — the + runtime binary itself always demands an explicit config. + """ + path = bundled_package_dir() / "runtime" / "cordis.yml" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the default runtime config at {path}" + ) + return path + + +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 or the executable + has not been placed into this package; the message names the acquisition + routes (acquisition strategy is deliberately separate from this lookup + interface, so an on-demand download can replace it without touching + callers). + """ + tag = _current_platform_tag() + path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + + _EXE_ACQUISITION_HINT + ) + return path + + +def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: + """The argv tuple that launches the bundled runtime. + + Mode selection: the explicit ``mode`` argument wins, then the + ``DSH_RUNTIME_MODE`` environment variable (``exe`` | ``node``), then + automatic resolution. Automatic resolution finds the production exe ONLY — + the dev-only node carrier must be selected explicitly so a production + deployment can never silently ride on a source build. Returns + ``(exe_path,)`` in exe mode and ``(node_path, bin_js_path)`` in node mode; + raises FileNotFoundError when the selected carrier is unavailable and + ValueError for an unknown mode value. + """ + selected = mode if mode is not None else os.environ.get(RUNTIME_MODE_ENV_VAR) + if selected is None or selected == "exe": + return (str(bundled_runtime_path()),) + if selected == "node": + return _node_launch_args() + raise ValueError( + f"unsupported DeepSeek Harness runtime mode {selected!r}: expected 'exe' or 'node' " + f"(explicit argument or ${RUNTIME_MODE_ENV_VAR})" + ) + + +def _current_platform_tag() -> str: + plat = _PLATFORM_TAGS.get(sys.platform) + arch = _ARCH_TAGS.get(platform.machine().lower()) + if plat is None or arch is None: + raise FileNotFoundError( + "no bundled dsh-jsonrpc-agent executable exists for this platform " + f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " + "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + ) + return f"{plat}-{arch}" + + +def _node_launch_args() -> tuple[str, str]: + node_root = bundled_package_dir() / "runtime" / "node" + bin_js = ( + node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-agent" / "lib" / "bin.js" + ) + if not bin_js.is_file(): + raise FileNotFoundError( + f"the dev-only node runtime closure is missing at {node_root} " + f"(no {bin_js}); run `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness " + "checkout, which builds and copies the deploy closure here. The node carrier " + "is for repo-local development only — production uses the single-file exe." + ) + node = shutil.which("node") + if node is None: + raise FileNotFoundError( + "the node runtime mode needs a system `node` (>=22.19) on PATH; " + "install Node.js or use the exe mode" + ) + return (node, str(bin_js)) + + +__all__ = [ + "PACKAGE_METADATA_FILENAME", + "RUNTIME_MODE_ENV_VAR", + "bundled_default_config_path", + "bundled_package_dir", + "bundled_runtime_path", + "resolve_bundled_launch_args", +] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json new file mode 100644 index 0000000000..e4d02d8a3d --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json @@ -0,0 +1 @@ +{"name":"deepseek-harness-runtime-bin","version":"0.0.0-dev"} diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml new file mode 100644 index 0000000000..76ae18b20e --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -0,0 +1,50 @@ +# Default runtime configuration for the bundled dsh-jsonrpc-agent. The runtime +# binary has NO built-in fallback — it always requires an explicit config via +# `$DSH_CORDIS_CONFIG` (wins) or an argv positional path. The Python client SDK +# injects THIS file's path via `$DSH_CORDIS_CONFIG` when the caller supplies +# no config and the launch resolves to the bundled runtime; that explicit +# injection is what restores the zero-config experience. The runtime bin only +# boots this config; the serving surface (the stdio JSON-RPC server) comes +# from the @deepseek-ai/dsh-jsonrpc entry below. +# +# $DSH_SESSION_ROOT and $DSH_CWD are set by the SDK per launch; the `!!js` +# fallbacks keep this file usable when the runtime is driven manually. + +# The serving surface: HarnessSdkServer + line-delimited JSON-RPC transport on +# stdio. Without this entry the runtime boots an agent nobody can talk to. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# The agent spine bundle: session store, system prompt, tool registry, agent +# registry, and the agent loop. No pre-created agents — the SDK server creates +# one per session/prompt sessionId. +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' + +# The DeepSeek adapter, preloaded for the stock models. The adapter fails loud +# at load without an API key, so keyless boots must still export a dummy +# DEEPSEEK_API_KEY (initialize/shutdown never call the model). baseURL falls +# back to the public endpoint when unset. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro + +# JSONL session persistence. $DSH_SESSION_ROOT (set by the SDK whenever +# `session_root` is configured) wins; otherwise ./.sessions relative to the +# runtime process cwd. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + +# Local bash executor behind the spine's `bash` tool. $DSH_CWD (always set by +# the SDK) wins; otherwise the runtime process cwd. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml new file mode 100644 index 0000000000..c21759e4b7 --- /dev/null +++ b/python/sdk/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 67dbd04520ac01318c1f21393c12b7bf4965b4cb +README.zh.md: 36ea4df4ff73e12ce3fda5bbee4108584dd5ad92 diff --git a/python/sdk/README.md b/python/sdk/README.md new file mode 100644 index 0000000000..67dbd04520 --- /dev/null +++ b/python/sdk/README.md @@ -0,0 +1,27 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The +runtime inherits normal DeepSeek Harness environment variables such as +`DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model +endpoints directly or point those variables at a local proxy during +benchmark runs. + +By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. + +```py +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`TurnResult.final_response` is the text content from the last +`assistant/message` event in the turn. Use `TurnResult.events` for the complete +event stream, including intermediate assistant messages and tool activity. + +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. If neither `cordis` nor `DSH_CORDIS_CONFIG` is set and the launch resolves to the bundled runtime, the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md new file mode 100644 index 0000000000..36ea4df4ff --- /dev/null +++ b/python/sdk/README.zh.md @@ -0,0 +1,21 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 + +默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 + +```py +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 + +同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。若 `cordis` 与 `DSH_CORDIS_CONFIG` 均未设置,且启动解析到内置运行时,则使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml new file mode 100644 index 0000000000..68760f7c61 --- /dev/null +++ b/python/sdk/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness" +version = "0.0.0-dev" +description = "Python SDK for DeepSeek Harness" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } +dependencies = [ + "pydantic>=2.12", + "deepseek-harness-runtime-bin==0.0.0-dev", +] + +[dependency-groups] +test = ["pytest>=8.0"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness"] + +# Editable: the runtime package's executables are injected into its source +# tree AFTER install (by scripts/build-exe-for-python-sdk.ts or a manual copy); an +# editable install sees them immediately instead of freezing a wheel snapshot. +[tool.uv.sources] +deepseek-harness-runtime-bin = { path = "../sdk-runtime", editable = true } diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py new file mode 100644 index 0000000000..fab791d4f6 --- /dev/null +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -0,0 +1,17 @@ +from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult +from .client import HarnessClient, HarnessConfig +from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo + +__all__ = [ + "DeepSeekHarness", + "DeepSeekHarnessConfig", + "Session", + "TurnResult", + "HarnessClient", + "HarnessConfig", + "IncomingRequest", + "InitializeResponse", + "JsonObject", + "Notification", + "ServerInfo", +] diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py new file mode 100644 index 0000000000..ab3b2f764e --- /dev/null +++ b/python/sdk/src/deepseek_harness/api.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .client import HarnessClient, HarnessConfig +from .models import JsonObject, Notification + + +@dataclass(slots=True) +class DeepSeekHarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime. + + The runtime inherits the caller's environment by default, so existing + DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to + intentionally override or inject variables for a subprocess. + """ + + model: str = "deepseek-v4-flash" + cwd: str | None = None + runtime_cwd: str | None = None + session_root: str | None = None + cordis: str | None = None + system_prompt: str | None = None + env: dict[str, str] = field(default_factory=dict) + runtime_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + client_name: str = "deepseek_harness_python_sdk" + client_version: str = "0.0.0-dev" + base_url: str | None = None + api_key: str | None = None + + +@dataclass(slots=True) +class TurnResult: + session_id: str + status: str + final_response: str + events: list[JsonObject] + notifications: list[Notification] + session_root: str | None = None + + +class DeepSeekHarness: + """Synchronous high-level SDK for running DeepSeek Harness agent turns.""" + + def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: + if config is not None and kwargs: + raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") + self.config = config or DeepSeekHarnessConfig(**kwargs) + cwd = self.config.cwd or str(Path.cwd()) + runtime_cwd = self.config.runtime_cwd or cwd + env = dict(self.config.env) + if self.config.session_root is not None: + env["DSH_SESSION_ROOT"] = self.config.session_root + if self.config.cordis is not None: + env["DSH_CORDIS_CONFIG"] = self.config.cordis + else: + self._inject_bundled_default_config(env) + env["DSH_CWD"] = cwd + if self.config.base_url is not None: + env["DEEPSEEK_BASE_URL"] = self.config.base_url + if self.config.api_key is not None: + env["DEEPSEEK_API_KEY"] = self.config.api_key + + self._client = HarnessClient( + HarnessConfig( + runtime_bin=self.config.runtime_bin, + launch_args_override=self.config.launch_args_override, + cwd=runtime_cwd, + env=env, + request_timeout_seconds=self.config.request_timeout_seconds, + shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, + client_name=self.config.client_name, + client_version=self.config.client_version, + ) + ) + self._initialized = False + + def __enter__(self) -> "DeepSeekHarness": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + @property + def client(self) -> HarnessClient: + return self._client + + def start(self) -> None: + if self._initialized: + return + self._client.start() + self._client.initialize( + cwd=self.config.cwd or str(Path.cwd()), + model=self.config.model, + session_root=self.config.session_root, + system_prompt=self.config.system_prompt, + ) + self._initialized = True + + def close(self) -> None: + self._client.close() + self._initialized = False + + def _inject_bundled_default_config(self, env: dict[str, str]) -> None: + """Restore the zero-config experience over the config-mandatory bundled runtime. + + The bundled runtime (single-file exe or the dev-only node closure) + always demands an explicit config. When the caller neither provided + ``cordis`` nor selected a runtime explicitly (``runtime_bin`` / + ``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists, + inject the runtime package's checked-in default cordis.yml. With an + explicit runtime or config channel the SDK stays out of the way. + """ + uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None + if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ: + return + try: + from deepseek_harness_runtime import bundled_default_config_path + except ImportError: + # Only the runtime package's absence reaches here; swallow it so + # HarnessClient.start() reports the actionable install error. + return + env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + + def start_session(self, session_id: str | None = None) -> "Session": + self.start() + return Session(self, session_id or f"session-{uuid.uuid4().hex}") + + def run( + self, + input: str | list[JsonObject], + *, + session_id: str | None = None, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + return self.start_session(session_id).run(input, profile=profile, on_notification=on_notification) + + +class Session: + def __init__(self, harness: DeepSeekHarness, session_id: str) -> None: + self.harness = harness + self.id = session_id + + def run( + self, + input: str | list[JsonObject], + *, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + content_blocks = normalize_input(input) + notifications: list[Notification] = [] + events: list[JsonObject] = [] + status = "error" + finished = False + + def collect(notification: Notification) -> None: + nonlocal finished, status + notifications.append(notification) + if on_notification is not None: + on_notification(notification) + if notification.method == "session.event": + event = notification.payload.get("event") + if isinstance(event, dict): + events.append(event) + if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id: + status = str(notification.payload.get("status") or "ok") + finished = True + + with self.harness.client.subscribe_session_notifications(self.id) as subscription: + self.harness.client.session_prompt( + self.id, + content_blocks, + profile=profile, + on_notification=collect, + notification_subscription=subscription, + ) + + while not finished: + notification = subscription.next() + collect(notification) + + return TurnResult( + session_id=self.id, + status=status, + final_response=final_response(events), + events=events, + notifications=notifications, + session_root=self.harness.config.session_root, + ) + + +def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: + if isinstance(input, str): + return [{"type": "text", "text": input}] + return input + + +def final_response(events: list[JsonObject]) -> str: + for event in reversed(events): + if event.get("type") != "assistant/message": + continue + data = event.get("data") + if not isinstance(data, dict): + continue + content = data.get("content") + if not isinstance(content, list): + continue + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "".join(parts) + return "" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py new file mode 100644 index 0000000000..541c1b47c8 --- /dev/null +++ b/python/sdk/src/deepseek_harness/client.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +import time +import uuid +from collections import deque +from dataclasses import dataclass +from typing import Callable, Literal, TypeAlias, TypeVar + +from pydantic import BaseModel + +from .errors import JsonRpcError, TransportClosedError +from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification + +ModelT = TypeVar("ModelT", bound=BaseModel) +NotificationFilter: TypeAlias = Callable[[Notification], bool] + + +@dataclass(slots=True) +class HarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime.""" + + runtime_bin: str | None = None + bridge_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + cwd: str | None = None + env: dict[str, str] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + client_name: str = "deepseek_harness_python_sdk" + client_version: str = "0.0.0-dev" + + +class HarnessClient: + """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" + + def __init__(self, config: HarnessConfig | None = None) -> None: + self.config = config or HarnessConfig() + self._proc: subprocess.Popen[str] | None = None + self._lock = threading.Lock() + self._write_lock = threading.Lock() + self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {} + self._notifications: queue.Queue[Notification | BaseException] = queue.Queue() + self._notification_subscribers: dict[ + str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] + ] = {} + self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() + self._stderr_lines: deque[str] = deque(maxlen=400) + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + + def __enter__(self) -> "HarnessClient": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def start(self) -> None: + if self._proc is not None: + return + args = list(self.config.launch_args_override or self._default_launch_args()) + env = os.environ.copy() + if self.config.env: + env.update(self.config.env) + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + cwd=self.config.cwd, + env=env, + bufsize=1, + ) + self._start_reader_thread() + self._start_stderr_thread() + + def close(self) -> None: + proc = self._proc + if proc is None: + return + try: + self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + except Exception as exc: + self._stderr_lines.append(f"shutdown request failed: {exc}") + self._proc = None + if proc.stdin: + try: + proc.stdin.close() + except Exception as exc: + self._stderr_lines.append(f"stdin close failed: {exc}") + try: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) + except Exception: + proc.kill() + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) + if self._reader_thread and self._reader_thread.is_alive(): + self._reader_thread.join(timeout=0.5) + if self._stderr_thread and self._stderr_thread.is_alive(): + self._stderr_thread.join(timeout=0.5) + + def initialize( + self, + *, + cwd: str, + model: str, + session_root: str | None = None, + system_prompt: str | None = None, + ) -> InitializeResponse: + payload: JsonObject = { + "clientInfo": { + "name": self.config.client_name, + "version": self.config.client_version, + }, + "cwd": cwd, + "model": model, + } + if session_root is not None: + payload["sessionRoot"] = session_root + if system_prompt is not None: + payload["systemPrompt"] = system_prompt + return self.request("initialize", payload, response_model=InitializeResponse) + + def session_prompt( + self, + session_id: str, + content_blocks: list[JsonObject], + *, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> None: + payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} + if profile is not None: + payload["profile"] = profile + self.request( + "session/prompt", + payload, + response_model=_SessionPromptResponse, + on_notification=on_notification, + notification_filter=_notification_belongs_to_session(session_id), + notification_subscription=notification_subscription, + ) + + def request( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> ModelT: + result = self._request_raw( + method, + params, + timeout_seconds=timeout_seconds, + on_notification=on_notification, + notification_filter=notification_filter, + notification_subscription=notification_subscription, + ) + if not isinstance(result, dict): + raise TypeError(f"{method} response must be a JSON object") + return response_model.model_validate(result) + + def notify(self, method: str, params: JsonObject | None = None) -> None: + message: JsonObject = {"jsonrpc": "2.0", "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + + def next_notification(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def subscribe_notifications( + self, + notification_filter: NotificationFilter | None = None, + ) -> "NotificationSubscription": + subscription_id = str(uuid.uuid4()) + notifications: queue.Queue[Notification | BaseException] = queue.Queue() + with self._lock: + self._notification_subscribers[subscription_id] = (notifications, notification_filter) + return NotificationSubscription(self, subscription_id, notifications) + + def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": + return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + + def next_request(self) -> IncomingRequest: + item = self._requests.get() + if isinstance(item, BaseException): + raise item + return item + + def respond(self, request_id: str | int, result: JsonValue) -> None: + self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result}) + + def respond_error( + self, + request_id: str | int, + *, + code: int, + message: str, + data: JsonValue | None = None, + ) -> None: + error: JsonObject = {"code": code, "message": message} + if data is not None: + error["data"] = data + self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error}) + + def _request_raw( + self, + method: str, + params: JsonObject | None = None, + *, + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> JsonValue: + request_id = str(uuid.uuid4()) + waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) + temp_subscription: NotificationSubscription | None = None + subscription = notification_subscription + with self._lock: + self._responses[request_id] = waiter + if on_notification is not None and subscription is None: + temp_subscription = self.subscribe_notifications(notification_filter) + subscription = temp_subscription + try: + message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds + deadline = None if timeout is None else time.monotonic() + timeout + try: + while True: + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + wait_timeout = None + if on_notification is not None: + wait_timeout = 0.05 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + with self._lock: + self._responses.pop(request_id, None) + raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime") + wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) + try: + item = waiter.get(timeout=wait_timeout) + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + break + except queue.Empty: + continue + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + finally: + if temp_subscription is not None: + temp_subscription.close() + if isinstance(item, BaseException): + raise item + return item + + def _write_message(self, message: JsonObject) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise TransportClosedError("DeepSeek Harness runtime is not running") + try: + payload = json.dumps(message, separators=(",", ":")) + "\n" + with self._write_lock: + proc.stdin.write(payload) + proc.stdin.flush() + except Exception as exc: + raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc + + def _start_reader_thread(self) -> None: + self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True) + self._reader_thread.start() + + def _start_stderr_thread(self) -> None: + self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True) + self._stderr_thread.start() + + def _reader_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + for line in proc.stdout: + if not line.strip(): + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + self._handle_message(message) + except BaseException as exc: + self._fail_waiters(exc) + finally: + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed")) + + def _stderr_loop(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for line in proc.stderr: + self._stderr_lines.append(line.rstrip()) + + def _handle_message(self, message: object) -> None: + if not isinstance(message, dict): + return + msg_id = message.get("id") + method = message.get("method") + if isinstance(msg_id, (str, int)) and isinstance(method, str): + params = message.get("params") + self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})) + return + if isinstance(msg_id, (str, int)): + with self._lock: + waiter = self._responses.pop(str(msg_id), None) + if waiter is None: + return + if isinstance(message.get("error"), dict): + err = message["error"] + waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data"))) + else: + waiter.put(message.get("result")) + return + if isinstance(method, str): + params = message.get("params") + notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) + with self._lock: + subscribers = list(self._notification_subscribers.values()) + delivered = False + for subscriber, predicate in subscribers: + if predicate is None or predicate(notification): + subscriber.put(notification) + delivered = True + if not delivered: + self._notifications.put(notification) + + def _fail_waiters(self, exc: BaseException) -> None: + with self._lock: + waiters = list(self._responses.values()) + self._responses.clear() + subscribers = list(self._notification_subscribers.values()) + self._notification_subscribers.clear() + for waiter in waiters: + waiter.put(exc) + for subscriber, _predicate in subscribers: + subscriber.put(exc) + self._notifications.put(exc) + self._requests.put(exc) + + def _runtime_closed_error(self, reason: str) -> TransportClosedError: + proc = self._proc + if ( + proc is not None + and proc.poll() is not None + and self._stderr_thread is not None + and self._stderr_thread.is_alive() + and threading.current_thread() is not self._stderr_thread + ): + self._stderr_thread.join(timeout=0.1) + + parts = [reason] + if proc is not None: + exit_code = proc.poll() + if exit_code is not None: + parts.append(f"exit code: {exit_code}") + if self._stderr_lines: + parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) + return TransportClosedError("\n".join(parts)) + + def _default_launch_args(self) -> tuple[str, ...]: + if self.config.runtime_bin is not None: + return (self.config.runtime_bin,) + if self.config.bridge_bin is not None: + return (self.config.bridge_bin,) + try: + from deepseek_harness_runtime import resolve_bundled_launch_args + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the bundled DeepSeek Harness SDK runtime. " + "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin." + ) from exc + return resolve_bundled_launch_args() + + def _unsubscribe_notifications(self, subscription_id: str) -> None: + with self._lock: + self._notification_subscribers.pop(subscription_id, None) + + +class NotificationSubscription: + def __init__( + self, + client: HarnessClient, + subscription_id: str, + notifications: queue.Queue[Notification | BaseException], + ) -> None: + self._client = client + self._subscription_id = subscription_id + self._notifications = notifications + self._closed = False + + def __enter__(self) -> "NotificationSubscription": + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._client._unsubscribe_notifications(self._subscription_id) + + def next(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def drain(self, on_notification: Callable[[Notification], None]) -> None: + while True: + try: + item = self._notifications.get_nowait() + except queue.Empty: + return + if isinstance(item, BaseException): + raise item + on_notification(item) + + +class _SessionPromptResponse(BaseModel): + accepted: Literal[True] + + +class _ShutdownResponse(BaseModel): + pass + + +def _int_or_none(value: object) -> int | None: + return value if isinstance(value, int) else None + + +def _notification_belongs_to_session(session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + return ( + payload.get("sessionId") == session_id + or payload.get("parentSessionId") == session_id + or payload.get("childSessionId") == session_id + ) + + return belongs diff --git a/python/sdk/src/deepseek_harness/errors.py b/python/sdk/src/deepseek_harness/errors.py new file mode 100644 index 0000000000..84cac8d3ce --- /dev/null +++ b/python/sdk/src/deepseek_harness/errors.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class HarnessError(Exception): + """Base exception for SDK and runtime failures.""" + + +class TransportClosedError(HarnessError): + """Raised when the runtime subprocess exits or closes stdout.""" + + +class JsonRpcError(HarnessError): + """Raised when the runtime returns a JSON-RPC error response.""" + + def __init__(self, code: int | None, message: str, data: object | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py new file mode 100644 index 0000000000..f398bb422d --- /dev/null +++ b/python/sdk/src/deepseek_harness/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from pydantic import BaseModel + +JsonScalar: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] +JsonObject: TypeAlias = dict[str, JsonValue] + + +@dataclass(slots=True) +class Notification: + method: str + payload: JsonObject + + +@dataclass(slots=True) +class IncomingRequest: + id: str | int + method: str + payload: JsonObject + + +class ServerInfo(BaseModel): + name: str | None = None + version: str | None = None + + +class InitializeResponse(BaseModel): + serverInfo: ServerInfo | None = None diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py new file mode 100644 index 0000000000..81ee36cfc9 --- /dev/null +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -0,0 +1,127 @@ +"""Manual keyless smoke: drive the repo-source jsonrpc-agent bin (node + tsx). + +Runs the SDK against `packages/ui/jsonrpc-agent/src/bin.ts` executed from the +repo checkout (requires `pnpm install`; no build, no API key — the model +endpoint is a local mock SSE server). The bin only boots the supplied +cordis.yml — the stdio JSON-RPC server itself comes from the config's +`@deepseek-ai/dsh-jsonrpc` entry — so the runtime package's default cordis.yml +is passed explicitly. Not collected by pytest; run it directly: +`python tests/manual_sdk_agent_smoke.py`. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from deepseek_harness import DeepSeekHarness +from deepseek_harness_runtime import bundled_default_config_path + + +class MockCompletionHandler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length).decode("utf-8") + self.requests.append({ + "path": self.path, + "authorization": self.headers.get("authorization"), + "body": json.loads(body), + }) + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') + self.wfile.write(b"data: [DONE]\n\n") + + def log_message(self, _format: str, *_args: object) -> None: + return + + +def run_smoke(repo_root: Path, keep_sessions: bool) -> None: + session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) + runtime_entry = repo_root / "packages/ui/jsonrpc-agent/src/bin.ts" + server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) + thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + print(f"repo_root={repo_root}") + print(f"session_root={session_root}") + print(f"mock_base_url={base_url}") + + try: + with DeepSeekHarness( + model="sdk-smoke-model", + cwd=str(repo_root / "python/sdk"), + runtime_cwd=str(repo_root), + session_root=str(session_root), + cordis=str(bundled_default_config_path()), + system_prompt="You are running a Python SDK smoke test.", + launch_args_override=("node", "--import", "tsx", str(runtime_entry)), + env={ + "DEEPSEEK_BASE_URL": base_url, + "DEEPSEEK_API_KEY": "sdk-smoke-key", + }, + request_timeout_seconds=20, + shutdown_timeout_seconds=2, + ) as harness: + result = harness.run( + "Please reply with a short confirmation and do not call tools.", + session_id="sdk-smoke-main", + profile="build", + ) + print(f"turn_status={result.status}") + print(f"final_response={result.final_response}") + assert result.status == "ok", result + assert "configured HTTP model endpoint" in result.final_response + assert len(MockCompletionHandler.requests) == 1 + request = MockCompletionHandler.requests[0] + print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) + assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["body"]["model"] == "sdk-smoke-model" + + jsonl_files = sorted(session_root.rglob("*.jsonl")) + assert jsonl_files, f"no jsonl sessions were written under {session_root}" + print("session_jsonl_files:") + for path in jsonl_files: + print(f" {path} bytes={path.stat().st_size}") + with path.open("r", encoding="utf-8") as handle: + first_line = handle.readline().strip() + if first_line: + print(f" first_line={first_line[:500]}") + finally: + server.shutdown() + server.server_close() + + if keep_sessions: + print(f"kept_session_root={session_root}") + else: + shutil.rmtree(session_root) + print("removed temporary session root") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[3], + help="Path to the deepseek-harness checkout.", + ) + parser.add_argument("--keep-sessions", action="store_true") + args = parser.parse_args() + run_smoke(args.repo_root.resolve(), args.keep_sessions) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py new file mode 100644 index 0000000000..51dfb668d8 --- /dev/null +++ b/python/sdk/tests/test_bundled_runtime.py @@ -0,0 +1,120 @@ +"""Smoke tests against the bundled dsh-jsonrpc-agent artifacts. + +These boot the runtime the way an installed SDK does, once per bundled +carrier: the platform single-file exe (production) and the dev-only node +closure under ``runtime/node`` driven by system ``node``. Each carrier skips +independently when its artifact is absent on this machine — build or fetch it +per the FileNotFoundError guidance quoted in the skip reason. Keyless: the +dummy DEEPSEEK_API_KEY only satisfies the adapter's load-time check; +initialize/shutdown never call a model. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness.errors import TransportClosedError +from deepseek_harness_runtime import bundled_default_config_path, resolve_bundled_launch_args + +_MODES = ("exe", "node") + +# The serving surface is itself a plugin: without the dsh-jsonrpc entry the +# runtime boots an agent nobody can talk to and exits 0 on stdin EOF. +_CORDIS_YML = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './sessions' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: '.' +- id: todo + name: '@deepseek-ai/dsh-tool-todo' +""" + + +def _launch_args(mode: str) -> tuple[str, ...]: + try: + return resolve_bundled_launch_args(mode) + except FileNotFoundError as exc: + pytest.skip(f"bundled {mode}-mode runtime unavailable on this machine: {exc}") + + +def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: + return HarnessClient( + HarnessConfig( + launch_args_override=launch_args, + cwd=str(tmp_path), + env={ + "DSH_CORDIS_CONFIG": "./cordis.yml", + "DSH_SESSION_ROOT": str(tmp_path / "sessions"), + "DSH_CWD": str(tmp_path), + # initialize() lazily mounts the llm-deepseek adapter for the + # requested model; a dummy key keeps the keyless boot green + # (initialize/shutdown never call the model). + "DEEPSEEK_API_KEY": "sk-dummy-for-boot", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", + }, + request_timeout_seconds=120, + ) + ) + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text(_CORDIS_YML) + + with _client(tmp_path, launch_args) as client: + init = client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + + assert init.serverInfo is not None + assert init.serverInfo.name == "deepseek-harness-sdk-runtime" + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text( + "- id: missing\n name: '@deepseek-ai/dsh-does-not-exist'\n" + ) + + client = _client(tmp_path, launch_args) + client.start() + try: + with pytest.raises((TransportClosedError, TimeoutError)) as excinfo: + client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + finally: + client.close() + + assert "@deepseek-ai/dsh-does-not-exist" in str(excinfo.value) + + +@pytest.mark.parametrize("mode", _MODES) +def test_zero_config_run_injects_bundled_default_cordis_config( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _launch_args(mode) # skip early when this carrier is unavailable + monkeypatch.setenv("DSH_RUNTIME_MODE", mode) + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + + harness = DeepSeekHarness( + model="deepseek-v4-pro", + cwd=str(tmp_path), + session_root=str(tmp_path / "sessions"), + api_key="sk-dummy-for-boot", + base_url="http://127.0.0.1:9", + request_timeout_seconds=120, + ) + assert harness.client.config.env is not None + assert harness.client.config.env["DSH_CORDIS_CONFIG"] == str(bundled_default_config_path()) + with harness: + pass # __enter__ boots the runtime on the injected default config; __exit__ shuts it down diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py new file mode 100644 index 0000000000..3b12a28865 --- /dev/null +++ b/python/sdk/tests/test_client.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import json +import sys +import threading +import time +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig + + +def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + env_dump = tmp_path / "env.json" + script.write_text( + """ +import json +import os +import sys + +env_dump = os.environ["ENV_DUMP"] +json.dump({ + "DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"), + "DEEPSEEK_BASE_URL": os.environ.get("DEEPSEEK_BASE_URL"), + "DSH_CWD": os.environ.get("DSH_CWD"), + "DSH_SESSION_ROOT": os.environ.get("DSH_SESSION_ROOT"), + "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"), +}, open(env_dump, "w")) + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.event", + "params": { + "sessionId": params["sessionId"], + "event": { + "type": "assistant/message", + "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + }, + }, + }), flush=True) + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.finished", + "params": {"sessionId": params["sessionId"], "status": "ok"}, + }), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + model="deepseek-v4-flash", + cwd=str(tmp_path), + cordis=str(tmp_path / "cordis.yml"), + session_root=str(tmp_path / "sessions"), + launch_args_override=(sys.executable, str(script)), + env={ + "ENV_DUMP": str(env_dump), + "DEEPSEEK_API_KEY": "env-key", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:4321", + }, + ) as harness: + result = harness.run("say hello", session_id="main") + + assert result.status == "ok" + assert result.final_response == "hello from runtime" + assert result.events[0]["type"] == "assistant/message" + dumped_env = json.loads(env_dump.read_text()) + assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" + assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" + assert dumped_env["DSH_CWD"] == str(tmp_path) + assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions") + assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml") + + +def test_session_run_invokes_notification_callback_before_returning(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + session = harness.start_session("main") + result = session.run( + "spawn a helper", + on_notification=lambda notification: seen.append(notification.method), + ) + + assert result.status == "ok" + assert seen == ["subagent.started", "session.finished"] + + +def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("spawn a helper", session_id="main") + + assert result.status == "ok" + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "subagent.finished", + "session.finished", + ] + + +def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "other", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("stay in your lane", session_id="main") + + assert result.status == "ok" + assert result.final_response == "right session" + assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main", "main"] + + +def test_high_level_session_run_does_not_accumulate_global_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + result = harness.run("one turn", session_id="main") + assert result.status == "ok" + assert harness.client._notifications.qsize() == 0 + + +def test_session_run_waits_for_late_finished_without_replaying_stale_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys +import time + +turn = 0 +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + turn += 1 + params = msg.get("params") or {} + session_id = params["sessionId"] + if turn == 1: + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + else: + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + time.sleep(0.05) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "second"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + first = harness.run("first turn", session_id="main") + second = harness.run("second turn", session_id="main") + + assert first.final_response == "first" + assert second.final_response == "second" + assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main", "main"] + + +def test_client_starts_subprocess_sends_requests_and_routes_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "llm/request", "params": {"requestId": "req-1", "sessionId": params["sessionId"], "model": "dsagent", "messages": []}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + notification = client.next_notification() + assert notification.method == "llm/request" + assert notification.payload["requestId"] == "req-1" + assert notification.payload["sessionId"] == "main" + + +def test_client_keeps_unmatched_notifications_available_globally_while_subscribed() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main"): + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "other", "event": {"type": "assistant/message"}}, + }) + + assert client._notifications.qsize() == 1 + notification = client._notifications.get_nowait() + assert not isinstance(notification, BaseException) + assert notification.method == "session.event" + assert notification.payload["sessionId"] == "other" + + +def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": False}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + client.initialize(cwd="/workspace", model="dsagent") + with pytest.raises(ValueError): + client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + + +def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": "bridge-req-1", "method": "llm.request", "params": {"requestId": "req-1", "sessionId": "main", "model": "dsagent", "messages": []}}), flush=True) + elif "id" in msg and "method" not in msg: + print(json.dumps({"jsonrpc": "2.0", "method": "response/seen", "params": {"result": msg.get("result")}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + + request = client.next_request() + assert request.id == "bridge-req-1" + assert request.method == "llm.request" + assert request.payload["requestId"] == "req-1" + + client.respond(request.id, {"content_blocks": [{"type": "text", "text": "done"}]}) + notification = client.next_notification() + assert notification.method == "response/seen" + assert notification.payload["result"]["content_blocks"][0]["text"] == "done" + + +def test_client_ignores_non_json_stdout_lines(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +print("node warning: experimental loader", flush=True) +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + +def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import time + +time.sleep(60) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=0.1, + ) + ) as client: + start = time.monotonic() + try: + client.initialize(cwd="/workspace", model="dsagent") + except TimeoutError: + assert time.monotonic() - start < 2 + else: + raise AssertionError("initialize should time out") + + +def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys +import time + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + time.sleep(60) +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + shutdown_timeout_seconds=0.1, + ) + ) + client.start() + client.initialize(cwd="/workspace", model="dsagent") + start = time.monotonic() + client.close() + assert time.monotonic() - start < 2 + + +def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: + HarnessClient().close() + + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client.start() + client.initialize(cwd="/workspace", model="dsagent") + client.close() + client.close() + + +def test_runtime_closed_error_includes_stderr_tail(tmp_path: Path) -> None: + script = tmp_path / "crashing_runtime.py" + script.write_text( + """ +import sys + +print("fatal bridge exploded", file=sys.stderr, flush=True) +sys.exit(42) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=2, + ) + ) as client: + with pytest.raises(Exception, match="fatal bridge exploded"): + client.initialize(cwd="/workspace", model="dsagent") + + +def test_client_serializes_concurrent_writes(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + output = tmp_path / "seen.jsonl" + script.write_text( + """ +import json +import os +import sys + +with open(os.environ["SEEN"], "w") as seen: + for line in sys.stdin: + seen.write(line) + seen.flush() + msg = json.loads(line) + if "id" in msg and msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif "id" in msg and msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + env={"SEEN": str(output)}, + ) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + threads = [ + threading.Thread(target=client.notify, args=(f"notice-{index}", {"index": index})) + for index in range(50) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + for line in output.read_text().splitlines(): + json.loads(line) + + +def test_client_uses_bundled_runtime_package_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + runtime = tmp_path / "dsh-jsonrpc-agent" + runtime.write_text( + """#!/usr/bin/env python3 +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "bundled-runtime"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + runtime.chmod(0o755) + + module_dir = tmp_path / "deepseek_harness_runtime" + module_dir.mkdir() + (module_dir / "__init__.py").write_text( + f""" +def resolve_bundled_launch_args(mode=None): + return ({str(runtime)!r},) +""".strip() + ) + + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + + with HarnessClient() as client: + init = client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert init.serverInfo.name == "bundled-runtime" + + +def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + monkeypatch.setattr(sys, "path", []) + + with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"): + HarnessClient().start() diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py new file mode 100644 index 0000000000..4549d4dd29 --- /dev/null +++ b/python/sdk/tests/test_runtime_resolution.py @@ -0,0 +1,43 @@ +"""Keyless tests for the deepseek_harness_runtime resolution API. + +These never launch a runtime, so they run everywhere regardless of which +bundled artifacts are present; the launch-and-boot coverage lives in +``test_bundled_runtime.py``. +""" + +from __future__ import annotations + +import pytest + +from deepseek_harness_runtime import ( + RUNTIME_MODE_ENV_VAR, + bundled_default_config_path, + bundled_package_dir, + resolve_bundled_launch_args, +) + + +def test_default_config_is_shipped_with_the_package() -> None: + path = bundled_default_config_path() + assert path == bundled_package_dir() / "runtime" / "cordis.yml" + assert "@deepseek-ai/dsh-agent-core" in path.read_text() + + +def test_unknown_explicit_mode_fails_loud() -> None: + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args("bogus") + + +def test_unknown_env_mode_fails_loud(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args() + + +def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + try: + args = resolve_bundled_launch_args("exe") + except FileNotFoundError: + return # explicit 'exe' was honored; only the artifact is missing + assert args[0].endswith(("-x64", "-arm64")) diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock new file mode 100644 index 0000000000..94219b95ad --- /dev/null +++ b/python/sdk/uv.lock @@ -0,0 +1,321 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "deepseek-harness" +version = "0.0.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "deepseek-harness-runtime-bin" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "deepseek-harness-runtime-bin", editable = "../sdk-runtime" }, + { name = "pydantic", specifier = ">=2.12" }, +] + +[package.metadata.requires-dev] +test = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 965aae795f..b03bd4d619 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -5,7 +5,10 @@ "docs/i18n/README.md", "docs/i18n/translation-rules.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", - "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" + "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", + "python/README.md", + "python/sdk-runtime/README.md", + "python/sdk/README.md" ], "excluded": [ "docs/AGENTS.md", @@ -14,6 +17,7 @@ "docs/tool-catalog.md", "docs/persistence-catalog.md", "docs/cordis-catalog/", - "docs/i18n/terminology.md" + "docs/i18n/terminology.md", + "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" ] } diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 3d80572e7c..b27fcfe885 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -55,8 +55,8 @@ const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') const writeMode = process.argv.includes('--write') -/** Scope of the bilingual contract: the root README and the docs tree. */ -const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml'] +/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml'] /** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ interface Manifest {