Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/sdk-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: 'SDK Python'

on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'docs/developers/sdk-python.md'
- 'docs/developers/_meta.ts'
- 'README.md'
- 'package.json'
- '.github/workflows/sdk-python.yml'
push:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/sdk-python/**'
- 'docs/developers/sdk-python.md'
- 'docs/developers/_meta.ts'
- 'README.md'
- 'package.json'
- '.github/workflows/sdk-python.yml'

jobs:
sdk-python:
name: 'SDK Python (${{ matrix.python-version }})'
runs-on: 'ubuntu-latest'
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5

- name: 'Set up Python'
uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5
with:
python-version: '${{ matrix.python-version }}'

- name: 'Install SDK test dependencies'
run: |
python -m pip install --upgrade pip
python -m pip install -e 'packages/sdk-python[dev]'

- name: 'Run Ruff'
run: 'python -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python'

- name: 'Run Ruff Format'
run: 'python -m ruff format --check --config packages/sdk-python/pyproject.toml packages/sdk-python'

- name: 'Run Mypy'
run: 'python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src'

- name: 'Run Pytest'
run: 'python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q'
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ As an open-source terminal agent, you can use Qwen Code in four primary ways:
1. Interactive mode (terminal UI)
2. Headless mode (scripts, CI)
3. IDE integration (VS Code, Zed)
4. TypeScript SDK
4. SDKs (TypeScript, Python, Java)

#### Interactive mode

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

#### TypeScript SDK
#### SDKs

Build on top of Qwen Code with the TypeScript SDK:
Build on top of Qwen Code with the available SDKs:

- [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
- TypeScript: [Use the Qwen Code SDK](./packages/sdk-typescript/README.md)
- Python: [Use the Python SDK](./packages/sdk-python/README.md)
- Java: [Use the Java SDK](./packages/sdk-java/qwencode/README.md)

Python SDK example:

```python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main() -> None:
result = query(
"Summarize the repository layout.",
{
"cwd": "/path/to/project",
"path_to_qwen_executable": "qwen",
},
)

async for message in result:
if is_sdk_result_message(message):
print(message["result"])


asyncio.run(main())
```

## Commands & Shortcuts

Expand Down
3 changes: 2 additions & 1 deletion docs/developers/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export default {
type: 'separator',
},
'sdk-typescript': 'Typescript SDK',
'sdk-java': 'Java SDK(alpha)',
'sdk-python': 'Python SDK (alpha)',
'sdk-java': 'Java SDK (alpha)',
'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code',
type: 'separator',
Expand Down
168 changes: 168 additions & 0 deletions docs/developers/sdk-python.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Python SDK

## `qwen-code-sdk`

`qwen-code-sdk` is an experimental Python SDK for Qwen Code. v1 targets the
existing `stream-json` CLI protocol and keeps the transport surface small and
testable.

## Scope

- Package name: `qwen-code-sdk`
- Import path: `qwen_code_sdk`
- Runtime requirement: Python `>=3.10`
- CLI dependency: external `qwen` executable is required in v1
- Transport scope: process transport only
- Not included in v1: ACP transport, SDK-embedded MCP servers

## Install

```bash
pip install qwen-code-sdk
```

If `qwen` is not on `PATH`, pass `path_to_qwen_executable` explicitly.

## Quick Start

```python
import asyncio

from qwen_code_sdk import is_sdk_result_message, query


async def main() -> None:
result = query(
"Explain the repository structure.",
{
"cwd": "/path/to/project",
"path_to_qwen_executable": "qwen",
},
)

async for message in result:
if is_sdk_result_message(message):
print(message["result"])


asyncio.run(main())
```

## API Surface

### Top-level entry points

- `query(prompt, options=None) -> Query`
- `query_sync(prompt, options=None) -> SyncQuery`

`prompt` supports either:

- `str` for single-turn requests
- `AsyncIterable[SDKUserMessage]` for multi-turn streams

### `Query`

- Async iterable over SDK messages
- `close()`
- `interrupt()`
- `set_model(model)`
- `set_permission_mode(mode)`
- `supported_commands()`
- `mcp_server_status()`
- `get_session_id()`
- `is_closed()`

### `QueryOptions`

Supported options in v1:

- `cwd`
- `model`
- `path_to_qwen_executable`
- `permission_mode`
- `can_use_tool`
- `env`
- `system_prompt`
- `append_system_prompt`
- `debug`
- `max_session_turns`
- `core_tools`
- `exclude_tools`
- `allowed_tools`
- `auth_type`
- `include_partial_messages`
- `resume`
- `continue_session`
- `session_id`
- `timeout`
- `mcp_servers`
- `stderr`

Session argument priority is fixed as:

1. `resume`
2. `continue_session`
3. `session_id`

## Permission Handling

When the CLI emits a `can_use_tool` control request, the SDK routes it through
`can_use_tool(tool_name, tool_input, context)`.

- Default behavior: deny
- Default timeout: 60 seconds
- Timeout fallback: deny
- Callback exceptions: converted to deny with an error message
- Callback context: `cancel_event`, `suggestions`, and `blocked_path`
- Callback contract: `can_use_tool` must be async with 3 positional arguments;
`stderr` must accept 1 positional string argument

## Error Model

- `ValidationError`: invalid options, invalid UUIDs, unsupported combinations
- `ControlRequestTimeoutError`: initialize, interrupt, or other control request
timed out
- `ProcessExitError`: CLI exited non-zero
- `AbortError`: control request or session was cancelled

## Troubleshooting

If the SDK cannot start the CLI:

- Verify `qwen --version` works in the target environment
- Pass `path_to_qwen_executable` if your shell uses `nvm`, `pyenv`, or other
non-standard PATH setup
- Use `debug=True` or `stderr=print` to surface CLI stderr while debugging

If session control calls time out:

- Check that the target `qwen` version supports `--input-format stream-json`
- Increase `timeout.control_request`
- Verify that no wrapper script is swallowing stdout/stderr

## Repository Integration

Repository-level helper commands:

- `npm run test:sdk:python`
- `npm run lint:sdk:python`
- `npm run typecheck:sdk:python`
- `npm run smoke:sdk:python -- --qwen qwen`

## Real E2E Smoke

For a real runtime check (actual `qwen` process + real model call), run from
the repository root. The npm helper uses `python3`, so ensure it resolves to a
Python `>=3.10` interpreter:

```bash
npm run smoke:sdk:python -- --qwen qwen
```

This script runs:

- async single-turn query
- async control flow (`supported_commands`, permission mode updates)
- sync `query_sync` query

It prints JSON and returns non-zero on failure.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"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",
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
Expand All @@ -53,9 +54,12 @@
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
"lint:sdk:python": "python3 -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python",
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src",
"smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run build && npm run bundle",
Expand Down
30 changes: 21 additions & 9 deletions packages/cli/src/ui/components/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -884,11 +884,25 @@ describe('SettingsDialog', () => {
</KeypressProvider>,
);

// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
stdin.write(TerminalKeys.DOWN_ARROW as string);
await wait();
stdin.write(TerminalKeys.ENTER as string);
await wait();
await waitFor(() => {
expect(lastFrame()).toContain('Tool Approval Mode');
});

const languageIndex = getDialogSettingKeys().indexOf('general.language');
expect(languageIndex).toBeGreaterThanOrEqual(0);

const press = async (key: string) => {
act(() => {
stdin.write(key);
});
await wait();
};

// Trigger a restart-required setting change by toggling the UI language setting.
for (let i = 0; i < languageIndex; i++) {
await press(TerminalKeys.DOWN_ARROW as string);
}
await press(TerminalKeys.ENTER as string);

await waitFor(() => {
expect(lastFrame()).toContain(
Expand All @@ -897,10 +911,8 @@ describe('SettingsDialog', () => {
});

// Switch scopes; restart prompt should remain visible.
stdin.write(TerminalKeys.TAB as string);
await wait();
stdin.write('2');
await wait();
await press(TerminalKeys.TAB as string);
await press('2');

await waitFor(() => {
expect(lastFrame()).toContain(
Expand Down
Loading
Loading