mirror of
https://github.com/anthropics/claude-code-sdk-python.git
synced 2025-08-04 20:28:06 +00:00
Initial Python SDK import
This commit is contained in:
parent
19c71ae9ca
commit
6ca3514261
22 changed files with 1774 additions and 1 deletions
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
"""Pytest configuration for tests."""
|
||||
|
||||
|
||||
# No async plugin needed since we're using sync tests with anyio.run()
|
115
tests/test_client.py
Normal file
115
tests/test_client.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
"""Tests for Claude SDK client functionality."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
from claude_code_sdk import AssistantMessage, ClaudeCodeOptions, query
|
||||
from claude_code_sdk.types import TextBlock
|
||||
|
||||
|
||||
class TestQueryFunction:
|
||||
"""Test the main query function."""
|
||||
|
||||
def test_query_single_prompt(self):
|
||||
"""Test query with a single prompt."""
|
||||
async def _test():
|
||||
with patch('claude_code_sdk._internal.client.InternalClient.process_query') as mock_process:
|
||||
# Mock the async generator
|
||||
async def mock_generator():
|
||||
yield AssistantMessage(
|
||||
content=[TextBlock(text="4")]
|
||||
)
|
||||
|
||||
mock_process.return_value = mock_generator()
|
||||
|
||||
messages = []
|
||||
async for msg in query(prompt="What is 2+2?"):
|
||||
messages.append(msg)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0], AssistantMessage)
|
||||
assert messages[0].content[0].text == "4"
|
||||
|
||||
anyio.run(_test)
|
||||
|
||||
def test_query_with_options(self):
|
||||
"""Test query with various options."""
|
||||
async def _test():
|
||||
with patch('claude_code_sdk._internal.client.InternalClient.process_query') as mock_process:
|
||||
async def mock_generator():
|
||||
yield AssistantMessage(
|
||||
content=[TextBlock(text="Hello!")]
|
||||
)
|
||||
|
||||
mock_process.return_value = mock_generator()
|
||||
|
||||
options = ClaudeCodeOptions(
|
||||
allowed_tools=["Read", "Write"],
|
||||
system_prompt="You are helpful",
|
||||
permission_mode="acceptEdits",
|
||||
max_turns=5
|
||||
)
|
||||
|
||||
messages = []
|
||||
async for msg in query(
|
||||
prompt="Hi",
|
||||
options=options
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
# Verify process_query was called with correct prompt and options
|
||||
mock_process.assert_called_once()
|
||||
call_args = mock_process.call_args
|
||||
assert call_args[1]['prompt'] == "Hi"
|
||||
assert call_args[1]['options'] == options
|
||||
|
||||
anyio.run(_test)
|
||||
|
||||
def test_query_with_cwd(self):
|
||||
"""Test query with custom working directory."""
|
||||
async def _test():
|
||||
with patch('claude_code_sdk._internal.client.SubprocessCLITransport') as mock_transport_class:
|
||||
mock_transport = AsyncMock()
|
||||
mock_transport_class.return_value = mock_transport
|
||||
|
||||
# Mock the message stream
|
||||
async def mock_receive():
|
||||
yield {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Done"}]
|
||||
}
|
||||
}
|
||||
yield {
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"cost_usd": 0.001,
|
||||
"duration_ms": 1000,
|
||||
"duration_api_ms": 800,
|
||||
"is_error": False,
|
||||
"num_turns": 1,
|
||||
"session_id": "test-session",
|
||||
"total_cost": 0.001
|
||||
}
|
||||
|
||||
mock_transport.receive_messages = mock_receive
|
||||
mock_transport.connect = AsyncMock()
|
||||
mock_transport.disconnect = AsyncMock()
|
||||
|
||||
options = ClaudeCodeOptions(cwd="/custom/path")
|
||||
messages = []
|
||||
async for msg in query(
|
||||
prompt="test",
|
||||
options=options
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
# Verify transport was created with correct parameters
|
||||
mock_transport_class.assert_called_once()
|
||||
call_kwargs = mock_transport_class.call_args.kwargs
|
||||
assert call_kwargs['prompt'] == "test"
|
||||
assert call_kwargs['options'].cwd == "/custom/path"
|
||||
|
||||
anyio.run(_test)
|
52
tests/test_errors.py
Normal file
52
tests/test_errors.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
"""Tests for Claude SDK error handling."""
|
||||
|
||||
from claude_code_sdk import (
|
||||
ClaudeSDKError,
|
||||
CLIConnectionError,
|
||||
CLIJSONDecodeError,
|
||||
CLINotFoundError,
|
||||
ProcessError,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorTypes:
|
||||
"""Test error types and their properties."""
|
||||
|
||||
def test_base_error(self):
|
||||
"""Test base ClaudeSDKError."""
|
||||
error = ClaudeSDKError("Something went wrong")
|
||||
assert str(error) == "Something went wrong"
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
def test_cli_not_found_error(self):
|
||||
"""Test CLINotFoundError."""
|
||||
error = CLINotFoundError("Claude Code not found")
|
||||
assert isinstance(error, ClaudeSDKError)
|
||||
assert "Claude Code not found" in str(error)
|
||||
|
||||
def test_connection_error(self):
|
||||
"""Test CLIConnectionError."""
|
||||
error = CLIConnectionError("Failed to connect to CLI")
|
||||
assert isinstance(error, ClaudeSDKError)
|
||||
assert "Failed to connect to CLI" in str(error)
|
||||
|
||||
def test_process_error(self):
|
||||
"""Test ProcessError with exit code and stderr."""
|
||||
error = ProcessError("Process failed", exit_code=1, stderr="Command not found")
|
||||
assert error.exit_code == 1
|
||||
assert error.stderr == "Command not found"
|
||||
assert "Process failed" in str(error)
|
||||
assert "exit code: 1" in str(error)
|
||||
assert "Command not found" in str(error)
|
||||
|
||||
def test_json_decode_error(self):
|
||||
"""Test CLIJSONDecodeError."""
|
||||
import json
|
||||
|
||||
try:
|
||||
json.loads("{invalid json}")
|
||||
except json.JSONDecodeError as e:
|
||||
error = CLIJSONDecodeError("{invalid json}", e)
|
||||
assert error.line == "{invalid json}"
|
||||
assert error.original_error == e
|
||||
assert "Failed to decode JSON" in str(error)
|
191
tests/test_integration.py
Normal file
191
tests/test_integration.py
Normal file
|
@ -0,0 +1,191 @@
|
|||
"""Integration tests for Claude SDK.
|
||||
|
||||
These tests verify end-to-end functionality with mocked CLI responses.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from claude_code_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeCodeOptions,
|
||||
CLINotFoundError,
|
||||
ResultMessage,
|
||||
query,
|
||||
)
|
||||
from claude_code_sdk.types import ToolUseBlock
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""End-to-end integration tests."""
|
||||
|
||||
def test_simple_query_response(self):
|
||||
"""Test a simple query with text response."""
|
||||
async def _test():
|
||||
with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class:
|
||||
mock_transport = AsyncMock()
|
||||
mock_transport_class.return_value = mock_transport
|
||||
|
||||
# Mock the message stream
|
||||
async def mock_receive():
|
||||
yield {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "2 + 2 equals 4"}],
|
||||
},
|
||||
}
|
||||
yield {
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"cost_usd": 0.001,
|
||||
"duration_ms": 1000,
|
||||
"duration_api_ms": 800,
|
||||
"is_error": False,
|
||||
"num_turns": 1,
|
||||
"session_id": "test-session",
|
||||
"total_cost": 0.001,
|
||||
}
|
||||
|
||||
mock_transport.receive_messages = mock_receive
|
||||
mock_transport.connect = AsyncMock()
|
||||
mock_transport.disconnect = AsyncMock()
|
||||
|
||||
# Run query
|
||||
messages = []
|
||||
async for msg in query(prompt="What is 2 + 2?"):
|
||||
messages.append(msg)
|
||||
|
||||
# Verify results
|
||||
assert len(messages) == 2
|
||||
|
||||
# Check assistant message
|
||||
assert isinstance(messages[0], AssistantMessage)
|
||||
assert len(messages[0].content) == 1
|
||||
assert messages[0].content[0].text == "2 + 2 equals 4"
|
||||
|
||||
# Check result message
|
||||
assert isinstance(messages[1], ResultMessage)
|
||||
assert messages[1].cost_usd == 0.001
|
||||
assert messages[1].session_id == "test-session"
|
||||
|
||||
|
||||
anyio.run(_test)
|
||||
def test_query_with_tool_use(self):
|
||||
"""Test query that uses tools."""
|
||||
async def _test():
|
||||
with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class:
|
||||
mock_transport = AsyncMock()
|
||||
mock_transport_class.return_value = mock_transport
|
||||
|
||||
# Mock the message stream with tool use
|
||||
async def mock_receive():
|
||||
yield {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Let me read that file for you.",
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool-123",
|
||||
"name": "Read",
|
||||
"input": {"file_path": "/test.txt"},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
yield {
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"cost_usd": 0.002,
|
||||
"duration_ms": 1500,
|
||||
"duration_api_ms": 1200,
|
||||
"is_error": False,
|
||||
"num_turns": 1,
|
||||
"session_id": "test-session-2",
|
||||
"total_cost": 0.002,
|
||||
}
|
||||
|
||||
mock_transport.receive_messages = mock_receive
|
||||
mock_transport.connect = AsyncMock()
|
||||
mock_transport.disconnect = AsyncMock()
|
||||
|
||||
# Run query with tools enabled
|
||||
messages = []
|
||||
async for msg in query(
|
||||
prompt="Read /test.txt",
|
||||
options=ClaudeCodeOptions(allowed_tools=["Read"]),
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
# Verify results
|
||||
assert len(messages) == 2
|
||||
|
||||
# Check assistant message with tool use
|
||||
assert isinstance(messages[0], AssistantMessage)
|
||||
assert len(messages[0].content) == 2
|
||||
assert messages[0].content[0].text == "Let me read that file for you."
|
||||
assert isinstance(messages[0].content[1], ToolUseBlock)
|
||||
assert messages[0].content[1].name == "Read"
|
||||
assert messages[0].content[1].input["file_path"] == "/test.txt"
|
||||
|
||||
|
||||
anyio.run(_test)
|
||||
def test_cli_not_found(self):
|
||||
"""Test handling when CLI is not found."""
|
||||
async def _test():
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), pytest.raises(CLINotFoundError) as exc_info:
|
||||
async for _ in query(prompt="test"):
|
||||
pass
|
||||
|
||||
assert "Claude Code requires Node.js" in str(exc_info.value)
|
||||
|
||||
|
||||
anyio.run(_test)
|
||||
def test_continuation_option(self):
|
||||
"""Test query with continue_conversation option."""
|
||||
async def _test():
|
||||
with patch("claude_code_sdk._internal.client.SubprocessCLITransport") as mock_transport_class:
|
||||
mock_transport = AsyncMock()
|
||||
mock_transport_class.return_value = mock_transport
|
||||
|
||||
# Mock the message stream
|
||||
async def mock_receive():
|
||||
yield {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Continuing from previous conversation",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
mock_transport.receive_messages = mock_receive
|
||||
mock_transport.connect = AsyncMock()
|
||||
mock_transport.disconnect = AsyncMock()
|
||||
|
||||
# Run query with continuation
|
||||
messages = []
|
||||
async for msg in query(
|
||||
prompt="Continue", options=ClaudeCodeOptions(continue_conversation=True)
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
# Verify transport was created with continuation option
|
||||
mock_transport_class.assert_called_once()
|
||||
call_kwargs = mock_transport_class.call_args.kwargs
|
||||
assert call_kwargs['options'].continue_conversation is True
|
||||
|
||||
anyio.run(_test)
|
129
tests/test_transport.py
Normal file
129
tests/test_transport.py
Normal file
|
@ -0,0 +1,129 @@
|
|||
"""Tests for Claude SDK transport layer."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from claude_code_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
|
||||
from claude_code_sdk.types import ClaudeCodeOptions
|
||||
|
||||
|
||||
class TestSubprocessCLITransport:
|
||||
"""Test subprocess transport implementation."""
|
||||
|
||||
def test_find_cli_not_found(self):
|
||||
"""Test CLI not found error."""
|
||||
from claude_code_sdk._errors import CLINotFoundError
|
||||
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), pytest.raises(CLINotFoundError) as exc_info:
|
||||
SubprocessCLITransport(
|
||||
prompt="test", options=ClaudeCodeOptions()
|
||||
)
|
||||
|
||||
assert "Claude Code requires Node.js" in str(exc_info.value)
|
||||
|
||||
def test_build_command_basic(self):
|
||||
"""Test building basic CLI command."""
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="Hello", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
||||
)
|
||||
|
||||
cmd = transport._build_command()
|
||||
assert cmd[0] == "/usr/bin/claude"
|
||||
assert "--output-format" in cmd
|
||||
assert "stream-json" in cmd
|
||||
assert "--print" in cmd
|
||||
assert "Hello" in cmd
|
||||
|
||||
def test_cli_path_accepts_pathlib_path(self):
|
||||
"""Test that cli_path accepts pathlib.Path objects."""
|
||||
from pathlib import Path
|
||||
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="Hello", options=ClaudeCodeOptions(), cli_path=Path("/usr/bin/claude")
|
||||
)
|
||||
|
||||
assert transport._cli_path == "/usr/bin/claude"
|
||||
|
||||
def test_build_command_with_options(self):
|
||||
"""Test building CLI command with options."""
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="test",
|
||||
options=ClaudeCodeOptions(
|
||||
system_prompt="Be helpful",
|
||||
allowed_tools=["Read", "Write"],
|
||||
disallowed_tools=["Bash"],
|
||||
model="claude-3-5-sonnet",
|
||||
permission_mode="acceptEdits",
|
||||
max_turns=5,
|
||||
),
|
||||
cli_path="/usr/bin/claude",
|
||||
)
|
||||
|
||||
cmd = transport._build_command()
|
||||
assert "--system-prompt" in cmd
|
||||
assert "Be helpful" in cmd
|
||||
assert "--allowedTools" in cmd
|
||||
assert "Read,Write" in cmd
|
||||
assert "--disallowedTools" in cmd
|
||||
assert "Bash" in cmd
|
||||
assert "--model" in cmd
|
||||
assert "claude-3-5-sonnet" in cmd
|
||||
assert "--permission-mode" in cmd
|
||||
assert "acceptEdits" in cmd
|
||||
assert "--max-turns" in cmd
|
||||
assert "5" in cmd
|
||||
|
||||
def test_session_continuation(self):
|
||||
"""Test session continuation options."""
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="Continue from before",
|
||||
options=ClaudeCodeOptions(continue_conversation=True, resume="session-123"),
|
||||
cli_path="/usr/bin/claude",
|
||||
)
|
||||
|
||||
cmd = transport._build_command()
|
||||
assert "--continue" in cmd
|
||||
assert "--resume" in cmd
|
||||
assert "session-123" in cmd
|
||||
|
||||
def test_connect_disconnect(self):
|
||||
"""Test connect and disconnect lifecycle."""
|
||||
async def _test():
|
||||
with patch("anyio.open_process") as mock_exec:
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = None
|
||||
mock_process.terminate = MagicMock()
|
||||
mock_process.wait = AsyncMock()
|
||||
mock_process.stdout = MagicMock()
|
||||
mock_process.stderr = MagicMock()
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
||||
)
|
||||
|
||||
await transport.connect()
|
||||
assert transport._process is not None
|
||||
assert transport.is_connected()
|
||||
|
||||
await transport.disconnect()
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
anyio.run(_test)
|
||||
|
||||
def test_receive_messages(self):
|
||||
"""Test parsing messages from CLI output."""
|
||||
# This test is simplified to just test the parsing logic
|
||||
# The full async stream handling is tested in integration tests
|
||||
transport = SubprocessCLITransport(
|
||||
prompt="test", options=ClaudeCodeOptions(), cli_path="/usr/bin/claude"
|
||||
)
|
||||
|
||||
# The actual message parsing is done by the client, not the transport
|
||||
# So we just verify the transport can be created and basic structure is correct
|
||||
assert transport._prompt == "test"
|
||||
assert transport._cli_path == "/usr/bin/claude"
|
117
tests/test_types.py
Normal file
117
tests/test_types.py
Normal file
|
@ -0,0 +1,117 @@
|
|||
"""Tests for Claude SDK type definitions."""
|
||||
|
||||
from claude_code_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeCodeOptions,
|
||||
ResultMessage,
|
||||
)
|
||||
from claude_code_sdk.types import TextBlock, ToolResultBlock, ToolUseBlock, UserMessage
|
||||
|
||||
|
||||
class TestMessageTypes:
|
||||
"""Test message type creation and validation."""
|
||||
|
||||
def test_user_message_creation(self):
|
||||
"""Test creating a UserMessage."""
|
||||
msg = UserMessage(content="Hello, Claude!")
|
||||
assert msg.content == "Hello, Claude!"
|
||||
|
||||
def test_assistant_message_with_text(self):
|
||||
"""Test creating an AssistantMessage with text content."""
|
||||
text_block = TextBlock(text="Hello, human!")
|
||||
msg = AssistantMessage(content=[text_block])
|
||||
assert len(msg.content) == 1
|
||||
assert msg.content[0].text == "Hello, human!"
|
||||
|
||||
def test_tool_use_block(self):
|
||||
"""Test creating a ToolUseBlock."""
|
||||
block = ToolUseBlock(
|
||||
id="tool-123",
|
||||
name="Read",
|
||||
input={"file_path": "/test.txt"}
|
||||
)
|
||||
assert block.id == "tool-123"
|
||||
assert block.name == "Read"
|
||||
assert block.input["file_path"] == "/test.txt"
|
||||
|
||||
def test_tool_result_block(self):
|
||||
"""Test creating a ToolResultBlock."""
|
||||
block = ToolResultBlock(
|
||||
tool_use_id="tool-123",
|
||||
content="File contents here",
|
||||
is_error=False
|
||||
)
|
||||
assert block.tool_use_id == "tool-123"
|
||||
assert block.content == "File contents here"
|
||||
assert block.is_error is False
|
||||
|
||||
def test_result_message(self):
|
||||
"""Test creating a ResultMessage."""
|
||||
msg = ResultMessage(
|
||||
subtype="success",
|
||||
cost_usd=0.01,
|
||||
duration_ms=1500,
|
||||
duration_api_ms=1200,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id="session-123",
|
||||
total_cost_usd=0.01
|
||||
)
|
||||
assert msg.subtype == "success"
|
||||
assert msg.cost_usd == 0.01
|
||||
assert msg.session_id == "session-123"
|
||||
|
||||
|
||||
class TestOptions:
|
||||
"""Test Options configuration."""
|
||||
|
||||
def test_default_options(self):
|
||||
"""Test Options with default values."""
|
||||
options = ClaudeCodeOptions()
|
||||
assert options.allowed_tools == []
|
||||
assert options.max_thinking_tokens == 8000
|
||||
assert options.system_prompt is None
|
||||
assert options.permission_mode is None
|
||||
assert options.continue_conversation is False
|
||||
assert options.disallowed_tools == []
|
||||
|
||||
def test_claude_code_options_with_tools(self):
|
||||
"""Test Options with built-in tools."""
|
||||
options = ClaudeCodeOptions(
|
||||
allowed_tools=["Read", "Write", "Edit"],
|
||||
disallowed_tools=["Bash"]
|
||||
)
|
||||
assert options.allowed_tools == ["Read", "Write", "Edit"]
|
||||
assert options.disallowed_tools == ["Bash"]
|
||||
|
||||
def test_claude_code_options_with_permission_mode(self):
|
||||
"""Test Options with permission mode."""
|
||||
options = ClaudeCodeOptions(permission_mode="bypassPermissions")
|
||||
assert options.permission_mode == "bypassPermissions"
|
||||
|
||||
def test_claude_code_options_with_system_prompt(self):
|
||||
"""Test Options with system prompt."""
|
||||
options = ClaudeCodeOptions(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
append_system_prompt="Be concise."
|
||||
)
|
||||
assert options.system_prompt == "You are a helpful assistant."
|
||||
assert options.append_system_prompt == "Be concise."
|
||||
|
||||
def test_claude_code_options_with_session_continuation(self):
|
||||
"""Test Options with session continuation."""
|
||||
options = ClaudeCodeOptions(
|
||||
continue_conversation=True,
|
||||
resume="session-123"
|
||||
)
|
||||
assert options.continue_conversation is True
|
||||
assert options.resume == "session-123"
|
||||
|
||||
def test_claude_code_options_with_model_specification(self):
|
||||
"""Test Options with model specification."""
|
||||
options = ClaudeCodeOptions(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
permission_prompt_tool_name="CustomTool"
|
||||
)
|
||||
assert options.model == "claude-3-5-sonnet-20241022"
|
||||
assert options.permission_prompt_tool_name == "CustomTool"
|
Loading…
Add table
Add a link
Reference in a new issue