Skip to content

Commit e384338

Browse files
doudouOUCqwencoder
andauthored
feat(SDK) Add Python SDK implementation for #3010 (#3494)
* Codex worktree snapshot: startup-cleanup Co-authored-by: Codex * Add Python SDK real smoke test Adds a repository-only real E2E smoke script for the Python SDK, plus npm and developer documentation entry points. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address review findings — bugs, type safety, and test coverage - Fix prepare_spawn_info: JS files now use "node" instead of sys.executable - Fix protocol.py: correct total=False misuse on 7 TypedDicts (required fields were optional) - Fix query.py: add _closed guard in _ensure_started, suppress exceptions in close() - Fix sync_query.py: prevent close() deadlock, add context manager, add timeouts - Fix transport.py: handle malformed JSON lines, add _closed guard in start() - Fix validation.py: use uuid.RFC_4122 instead of magic UUID - Fix __init__.py: export TextBlock, widen query_sync signature - Remove dead code: ensure_not_aborted, write_json_line, _thread_error - Add 12 new tests (29 → 41): context managers, JSON skip, closed guards, spawn info, timeouts Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address wenshao review — session_id, bool validation, debug stderr - Fix continue_session=True generating a wrong random session_id - Add _as_optional_bool helper for strict type validation on bool fields - Default debug stderr to sys.stderr when no custom callback is provided Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address remaining wenshao review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): harden settings dialog restart prompt test Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): review fixes — UUID compat, stderr fallback, sync cleanup - Remove UUID version restriction to support v6/v7/v8 (RFC 9562) - Always write to sys.stderr when stderr callback raises (was silent when debug=False) - Prevent duplicate _STOP sentinel in SyncQuery.close() via _stop_sent flag - Add ruff format --check to CI workflow - Fix smoke_real.py version guard: fail early before imports instead of NameError - Apply ruff format to existing files Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): remaining review fixes — exit_code attr, guard strictness, sync timeout - Add exit_code attribute to ProcessExitError for programmatic access - Strengthen is_control_response/is_control_cancel guards to require payload fields, preventing misrouting of malformed messages - Expose control_request_timeout property on Query so SyncQuery uses the configured timeout instead of a hardcoded 30s default - Use dataclasses.replace() instead of direct mutation on frozen-style QueryOptions in query() factory - Add ResourceWarning in SyncQuery.__del__ when not properly closed Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): add exit_code default and guard __del__ against partial GC - Give ProcessExitError.exit_code a default value (-1) so user code can construct the exception with just a message string - Wrap SyncQuery.__del__ in try/except AttributeError to prevent crashes when the object is partially garbage-collected Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): review fixes — resource leak, type safety, CI matrix, docs - Fix SyncQuery.__del__ to call close() on GC instead of only warning - Replace hasattr duck-type check with isinstance(prompt, AsyncIterable) - Type-validate permission_mode/auth_type in QueryOptions.from_mapping - Use TypeGuard return types on all is_sdk_*/is_control_* predicates - Add 5s margin to sync wrapper timeouts to prevent error type masking - Expand CI matrix to test Python 3.10, 3.11, 3.12 - Change ProcessExitError.exit_code default from -1 to None - Add stderr to docs QueryOptions listing - Update README sync example to use context manager pattern Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): preserve iterator exhaustion state and suppress detached task warning - Add _exhausted flag to Query.__anext__ and SyncQuery.__next__ so repeated iteration after end-of-stream raises Stop(Async)Iteration instead of blocking forever. - Remove re-raise in _initialize() to prevent asyncio "Task exception was never retrieved" warning on detached tasks; the error is already surfaced via _finish_with_error(). Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): reject mcp_servers at validation time and add iterator/init tests - Reject mcp_servers in validate_query_options() with a clear error instead of advertising MCP support to the CLI and then failing at runtime when mcp_message arrives. - Remove dead mcp_servers branch from _initialize(). - Add tests for async/sync iterator exhaustion, detached init task warning suppression, and mcp_servers validation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): fix ruff lint errors in new tests - Use ControlRequestTimeoutError instead of bare Exception (B017) - Fix import sorting for stdlib vs third-party (I001) - Break long line to stay within 88-char limit (E501) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * style(sdk-python): apply ruff format to new tests Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 202be6e commit e384338

25 files changed

Lines changed: 4676 additions & 14 deletions

.github/workflows/sdk-python.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: 'SDK Python'
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- 'main'
7+
- 'release/**'
8+
paths:
9+
- 'packages/sdk-python/**'
10+
- 'docs/developers/sdk-python.md'
11+
- 'docs/developers/_meta.ts'
12+
- 'README.md'
13+
- 'package.json'
14+
- '.github/workflows/sdk-python.yml'
15+
push:
16+
branches:
17+
- 'main'
18+
- 'release/**'
19+
paths:
20+
- 'packages/sdk-python/**'
21+
- 'docs/developers/sdk-python.md'
22+
- 'docs/developers/_meta.ts'
23+
- 'README.md'
24+
- 'package.json'
25+
- '.github/workflows/sdk-python.yml'
26+
27+
jobs:
28+
sdk-python:
29+
name: 'SDK Python (${{ matrix.python-version }})'
30+
runs-on: 'ubuntu-latest'
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
python-version: ['3.10', '3.11', '3.12']
35+
steps:
36+
- name: 'Checkout'
37+
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
38+
39+
- name: 'Set up Python'
40+
uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5
41+
with:
42+
python-version: '${{ matrix.python-version }}'
43+
44+
- name: 'Install SDK test dependencies'
45+
run: |
46+
python -m pip install --upgrade pip
47+
python -m pip install -e 'packages/sdk-python[dev]'
48+
49+
- name: 'Run Ruff'
50+
run: 'python -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python'
51+
52+
- name: 'Run Ruff Format'
53+
run: 'python -m ruff format --check --config packages/sdk-python/pyproject.toml packages/sdk-python'
54+
55+
- name: 'Run Mypy'
56+
run: 'python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src'
57+
58+
- name: 'Run Pytest'
59+
run: 'python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q'

README.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ As an open-source terminal agent, you can use Qwen Code in four primary ways:
424424
1. Interactive mode (terminal UI)
425425
2. Headless mode (scripts, CI)
426426
3. IDE integration (VS Code, Zed)
427-
4. TypeScript SDK
427+
4. SDKs (TypeScript, Python, Java)
428428

429429
#### Interactive mode
430430

@@ -452,11 +452,38 @@ Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs):
452452
- [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/)
453453
- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/)
454454

455-
#### TypeScript SDK
455+
#### SDKs
456456

457-
Build on top of Qwen Code with the TypeScript SDK:
457+
Build on top of Qwen Code with the available SDKs:
458458

459-
- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
459+
- TypeScript: [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
460+
- Python: [Use the Python SDK](./packages/sdk-python/README.md)
461+
- Java: [Use the Java SDK](./packages/sdk-java/qwencode/README.md)
462+
463+
Python SDK example:
464+
465+
```python
466+
import asyncio
467+
468+
from qwen_code_sdk import is_sdk_result_message, query
469+
470+
471+
async def main() -> None:
472+
result = query(
473+
"Summarize the repository layout.",
474+
{
475+
"cwd": "/path/to/project",
476+
"path_to_qwen_executable": "qwen",
477+
},
478+
)
479+
480+
async for message in result:
481+
if is_sdk_result_message(message):
482+
print(message["result"])
483+
484+
485+
asyncio.run(main())
486+
```
460487

461488
## Commands & Shortcuts
462489

docs/developers/_meta.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default {
1111
type: 'separator',
1212
},
1313
'sdk-typescript': 'Typescript SDK',
14-
'sdk-java': 'Java SDK(alpha)',
14+
'sdk-python': 'Python SDK (alpha)',
15+
'sdk-java': 'Java SDK (alpha)',
1516
'Dive Into Qwen Code': {
1617
title: 'Dive Into Qwen Code',
1718
type: 'separator',

docs/developers/sdk-python.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Python SDK
2+
3+
## `qwen-code-sdk`
4+
5+
`qwen-code-sdk` is an experimental Python SDK for Qwen Code. v1 targets the
6+
existing `stream-json` CLI protocol and keeps the transport surface small and
7+
testable.
8+
9+
## Scope
10+
11+
- Package name: `qwen-code-sdk`
12+
- Import path: `qwen_code_sdk`
13+
- Runtime requirement: Python `>=3.10`
14+
- CLI dependency: external `qwen` executable is required in v1
15+
- Transport scope: process transport only
16+
- Not included in v1: ACP transport, SDK-embedded MCP servers
17+
18+
## Install
19+
20+
```bash
21+
pip install qwen-code-sdk
22+
```
23+
24+
If `qwen` is not on `PATH`, pass `path_to_qwen_executable` explicitly.
25+
26+
## Quick Start
27+
28+
```python
29+
import asyncio
30+
31+
from qwen_code_sdk import is_sdk_result_message, query
32+
33+
34+
async def main() -> None:
35+
result = query(
36+
"Explain the repository structure.",
37+
{
38+
"cwd": "/path/to/project",
39+
"path_to_qwen_executable": "qwen",
40+
},
41+
)
42+
43+
async for message in result:
44+
if is_sdk_result_message(message):
45+
print(message["result"])
46+
47+
48+
asyncio.run(main())
49+
```
50+
51+
## API Surface
52+
53+
### Top-level entry points
54+
55+
- `query(prompt, options=None) -> Query`
56+
- `query_sync(prompt, options=None) -> SyncQuery`
57+
58+
`prompt` supports either:
59+
60+
- `str` for single-turn requests
61+
- `AsyncIterable[SDKUserMessage]` for multi-turn streams
62+
63+
### `Query`
64+
65+
- Async iterable over SDK messages
66+
- `close()`
67+
- `interrupt()`
68+
- `set_model(model)`
69+
- `set_permission_mode(mode)`
70+
- `supported_commands()`
71+
- `mcp_server_status()`
72+
- `get_session_id()`
73+
- `is_closed()`
74+
75+
### `QueryOptions`
76+
77+
Supported options in v1:
78+
79+
- `cwd`
80+
- `model`
81+
- `path_to_qwen_executable`
82+
- `permission_mode`
83+
- `can_use_tool`
84+
- `env`
85+
- `system_prompt`
86+
- `append_system_prompt`
87+
- `debug`
88+
- `max_session_turns`
89+
- `core_tools`
90+
- `exclude_tools`
91+
- `allowed_tools`
92+
- `auth_type`
93+
- `include_partial_messages`
94+
- `resume`
95+
- `continue_session`
96+
- `session_id`
97+
- `timeout`
98+
- `mcp_servers`
99+
- `stderr`
100+
101+
Session argument priority is fixed as:
102+
103+
1. `resume`
104+
2. `continue_session`
105+
3. `session_id`
106+
107+
## Permission Handling
108+
109+
When the CLI emits a `can_use_tool` control request, the SDK routes it through
110+
`can_use_tool(tool_name, tool_input, context)`.
111+
112+
- Default behavior: deny
113+
- Default timeout: 60 seconds
114+
- Timeout fallback: deny
115+
- Callback exceptions: converted to deny with an error message
116+
- Callback context: `cancel_event`, `suggestions`, and `blocked_path`
117+
- Callback contract: `can_use_tool` must be async with 3 positional arguments;
118+
`stderr` must accept 1 positional string argument
119+
120+
## Error Model
121+
122+
- `ValidationError`: invalid options, invalid UUIDs, unsupported combinations
123+
- `ControlRequestTimeoutError`: initialize, interrupt, or other control request
124+
timed out
125+
- `ProcessExitError`: CLI exited non-zero
126+
- `AbortError`: control request or session was cancelled
127+
128+
## Troubleshooting
129+
130+
If the SDK cannot start the CLI:
131+
132+
- Verify `qwen --version` works in the target environment
133+
- Pass `path_to_qwen_executable` if your shell uses `nvm`, `pyenv`, or other
134+
non-standard PATH setup
135+
- Use `debug=True` or `stderr=print` to surface CLI stderr while debugging
136+
137+
If session control calls time out:
138+
139+
- Check that the target `qwen` version supports `--input-format stream-json`
140+
- Increase `timeout.control_request`
141+
- Verify that no wrapper script is swallowing stdout/stderr
142+
143+
## Repository Integration
144+
145+
Repository-level helper commands:
146+
147+
- `npm run test:sdk:python`
148+
- `npm run lint:sdk:python`
149+
- `npm run typecheck:sdk:python`
150+
- `npm run smoke:sdk:python -- --qwen qwen`
151+
152+
## Real E2E Smoke
153+
154+
For a real runtime check (actual `qwen` process + real model call), run from
155+
the repository root. The npm helper uses `python3`, so ensure it resolves to a
156+
Python `>=3.10` interpreter:
157+
158+
```bash
159+
npm run smoke:sdk:python -- --qwen qwen
160+
```
161+
162+
This script runs:
163+
164+
- async single-turn query
165+
- async control flow (`supported_commands`, permission mode updates)
166+
- sync `query_sync` query
167+
168+
It prints JSON and returns non-zero on failure.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
4444
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
4545
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
46+
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
4647
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
4748
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
4849
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
@@ -53,9 +54,12 @@
5354
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
5455
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
5556
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
57+
"lint:sdk:python": "python3 -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python",
5658
"lint:all": "node scripts/lint.js",
5759
"format": "prettier --experimental-cli --write .",
5860
"typecheck": "npm run typecheck --workspaces --if-present",
61+
"typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src",
62+
"smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py",
5963
"check-i18n": "npm run check-i18n --workspace=packages/cli",
6064
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
6165
"prepare": "husky && npm run build && npm run bundle",

packages/cli/src/ui/components/SettingsDialog.test.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -963,11 +963,25 @@ describe('SettingsDialog', () => {
963963
</KeypressProvider>,
964964
);
965965

966-
// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
967-
stdin.write(TerminalKeys.DOWN_ARROW as string);
968-
await wait();
969-
stdin.write(TerminalKeys.ENTER as string);
970-
await wait();
966+
await waitFor(() => {
967+
expect(lastFrame()).toContain('Tool Approval Mode');
968+
});
969+
970+
const languageIndex = getDialogSettingKeys().indexOf('general.language');
971+
expect(languageIndex).toBeGreaterThanOrEqual(0);
972+
973+
const press = async (key: string) => {
974+
act(() => {
975+
stdin.write(key);
976+
});
977+
await wait();
978+
};
979+
980+
// Trigger a restart-required setting change by toggling the UI language setting.
981+
for (let i = 0; i < languageIndex; i++) {
982+
await press(TerminalKeys.DOWN_ARROW as string);
983+
}
984+
await press(TerminalKeys.ENTER as string);
971985

972986
await waitFor(() => {
973987
expect(lastFrame()).toContain(
@@ -976,10 +990,8 @@ describe('SettingsDialog', () => {
976990
});
977991

978992
// Switch scopes; restart prompt should remain visible.
979-
stdin.write(TerminalKeys.TAB as string);
980-
await wait();
981-
stdin.write('2');
982-
await wait();
993+
await press(TerminalKeys.TAB as string);
994+
await press('2');
983995

984996
await waitFor(() => {
985997
expect(lastFrame()).toContain(

0 commit comments

Comments
 (0)