mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
* 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>
291 lines
7.8 KiB
Python
291 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from qwen_code_sdk.transport import build_cli_arguments, prepare_spawn_info
|
|
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
|
|
|
|
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
|
|
|
|
|
class DummyProcess:
|
|
def __init__(self) -> None:
|
|
self.stdin = None
|
|
self.stdout = None
|
|
self.stderr = None
|
|
self.returncode = 0
|
|
|
|
|
|
def test_build_cli_arguments_maps_supported_options() -> None:
|
|
args = build_cli_arguments(
|
|
QueryOptions(
|
|
model="qwen3-coder",
|
|
system_prompt="system prompt",
|
|
append_system_prompt="append prompt",
|
|
permission_mode="auto-edit",
|
|
max_session_turns=7,
|
|
core_tools=["Read", "Edit"],
|
|
exclude_tools=["Bash(rm *)"],
|
|
allowed_tools=["Bash(git status)"],
|
|
auth_type="openai",
|
|
include_partial_messages=True,
|
|
session_id=VALID_UUID,
|
|
)
|
|
)
|
|
|
|
assert args == [
|
|
"--input-format",
|
|
"stream-json",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--channel=SDK",
|
|
"--model",
|
|
"qwen3-coder",
|
|
"--system-prompt",
|
|
"system prompt",
|
|
"--append-system-prompt",
|
|
"append prompt",
|
|
"--approval-mode",
|
|
"auto-edit",
|
|
"--max-session-turns",
|
|
"7",
|
|
"--core-tools",
|
|
"Read,Edit",
|
|
"--exclude-tools",
|
|
"Bash(rm *)",
|
|
"--allowed-tools",
|
|
"Bash(git status)",
|
|
"--auth-type",
|
|
"openai",
|
|
"--include-partial-messages",
|
|
"--session-id",
|
|
VALID_UUID,
|
|
]
|
|
|
|
|
|
def test_cli_argument_precedence_prefers_resume_then_continue_then_session_id() -> None:
|
|
args = build_cli_arguments(
|
|
QueryOptions(
|
|
resume=VALID_UUID,
|
|
continue_session=True,
|
|
session_id="223e4567-e89b-12d3-a456-426614174000",
|
|
)
|
|
)
|
|
|
|
assert "--resume" in args
|
|
assert "--continue" not in args
|
|
assert "--session-id" not in args
|
|
|
|
|
|
def test_prepare_spawn_info_uses_runtime_for_python_scripts(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "fake-qwen.py"
|
|
script_path.write_text("print('ok')\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == sys.executable
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_javascript_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "fake-qwen.js"
|
|
script_path.write_text("console.log('ok');\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_keeps_plain_command_names() -> None:
|
|
spawn_info = prepare_spawn_info("qwen-custom")
|
|
|
|
assert spawn_info.command == "qwen-custom"
|
|
assert spawn_info.args == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_discards_stderr_when_debug_is_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> DummyProcess:
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
return DummyProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
|
|
await transport.start()
|
|
|
|
assert captured["kwargs"]["stderr"] is subprocess.DEVNULL
|
|
|
|
|
|
def test_prepare_spawn_info_defaults_to_qwen_when_none() -> None:
|
|
spawn_info = prepare_spawn_info(None)
|
|
|
|
assert spawn_info.command == "qwen"
|
|
assert spawn_info.args == []
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_mjs_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "cli.mjs"
|
|
script_path.write_text("export default {};\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
def test_prepare_spawn_info_uses_node_for_cjs_files(tmp_path: Path) -> None:
|
|
script_path = tmp_path / "cli.cjs"
|
|
script_path.write_text("module.exports = {};\n", encoding="utf-8")
|
|
|
|
spawn_info = prepare_spawn_info(str(script_path))
|
|
|
|
assert spawn_info.command == "node"
|
|
assert spawn_info.args == [str(script_path.resolve())]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_start_raises_after_close(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> DummyProcess:
|
|
return DummyProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
transport._closed = True
|
|
|
|
with pytest.raises(RuntimeError, match="Transport is closed"):
|
|
await transport.start()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_messages_skips_malformed_json_lines() -> None:
|
|
"""Malformed JSON lines should be skipped, not crash the stream."""
|
|
|
|
class FakeStdout:
|
|
def __init__(self, lines: list[bytes]) -> None:
|
|
self._lines = iter(lines)
|
|
|
|
async def readline(self) -> bytes:
|
|
return next(self._lines, b"")
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(timeout=TimeoutOptions())
|
|
)
|
|
|
|
class FakeProcess:
|
|
returncode = 0
|
|
stdin = None
|
|
stderr = None
|
|
|
|
def __init__(self) -> None:
|
|
self.stdout = FakeStdout(
|
|
[
|
|
b"not valid json\n",
|
|
b'{"type":"system","subtype":"init","uuid":"u","session_id":"s"}\n',
|
|
b"also bad\n",
|
|
b"",
|
|
]
|
|
)
|
|
|
|
async def wait(self) -> int:
|
|
return 0
|
|
|
|
transport._process = FakeProcess()
|
|
|
|
messages: list[Any] = []
|
|
async for msg in transport.read_messages():
|
|
messages.append(msg)
|
|
|
|
assert len(messages) == 1
|
|
assert messages[0]["type"] == "system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stderr_callback_exceptions_do_not_fail_transport() -> None:
|
|
class FakeStdout:
|
|
async def readline(self) -> bytes:
|
|
return b""
|
|
|
|
class FakeStderr:
|
|
def __init__(self) -> None:
|
|
self._lines = iter([b"error message\n", b""])
|
|
|
|
async def readline(self) -> bytes:
|
|
return next(self._lines, b"")
|
|
|
|
transport_module = __import__(
|
|
"qwen_code_sdk.transport",
|
|
fromlist=["ProcessTransport"],
|
|
)
|
|
|
|
callback_calls = 0
|
|
|
|
def stderr_callback(text: str) -> None:
|
|
nonlocal callback_calls
|
|
callback_calls += 1
|
|
assert text == "error message"
|
|
raise RuntimeError("sink failed")
|
|
|
|
transport = transport_module.ProcessTransport(
|
|
QueryOptions(
|
|
stderr=stderr_callback,
|
|
timeout=TimeoutOptions(),
|
|
)
|
|
)
|
|
|
|
class FakeProcess:
|
|
returncode = 0
|
|
stdin = None
|
|
|
|
def __init__(self) -> None:
|
|
self.stdout = FakeStdout()
|
|
self.stderr = FakeStderr()
|
|
|
|
async def wait(self) -> int:
|
|
return 0
|
|
|
|
transport._process = FakeProcess()
|
|
transport._stderr_task = asyncio.create_task(transport._forward_stderr())
|
|
|
|
await transport.wait_for_exit()
|
|
|
|
assert callback_calls == 1
|