mirror of
https://github.com/anthropics/claude-agent-sdk-python.git
synced 2025-10-06 01:00:03 +03:00
feat: add stderr callback to capture CLI debug output (#170)
## Summary - Add stderr callback option to ClaudeCodeOptions to capture CLI subprocess stderr output - Matches TypeScript SDK's stderr callback behavior for feature parity - Useful for debugging and monitoring CLI operations ## Changes - Added `stderr: Callable[[str], None] | None` field to `ClaudeCodeOptions` - Updated `SubprocessCLITransport` to handle stderr streaming with async task - Added example demonstrating stderr callback usage - Added e2e tests to verify functionality ## Test plan - [x] Run e2e tests: `python -m pytest e2e-tests/test_stderr_callback.py -v` - [x] Run example: `python examples/stderr_callback_example.py` - [x] Verify backward compatibility with existing `debug_stderr` field - [x] All linting and type checks pass 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
49
e2e-tests/test_stderr_callback.py
Normal file
49
e2e-tests/test_stderr_callback.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""End-to-end test for stderr callback functionality."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from claude_code_sdk import ClaudeCodeOptions, query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stderr_callback_captures_debug_output():
|
||||||
|
"""Test that stderr callback receives debug output when enabled."""
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
def capture_stderr(line: str):
|
||||||
|
stderr_lines.append(line)
|
||||||
|
|
||||||
|
# Enable debug mode to generate stderr output
|
||||||
|
options = ClaudeCodeOptions(
|
||||||
|
stderr=capture_stderr,
|
||||||
|
extra_args={"debug-to-stderr": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run a simple query
|
||||||
|
async for _ in query(prompt="What is 1+1?", options=options):
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Verify we captured debug output
|
||||||
|
assert len(stderr_lines) > 0, "Should capture stderr output with debug enabled"
|
||||||
|
assert any("[DEBUG]" in line for line in stderr_lines), "Should contain DEBUG messages"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stderr_callback_without_debug():
|
||||||
|
"""Test that stderr callback works but receives no output without debug mode."""
|
||||||
|
stderr_lines = []
|
||||||
|
|
||||||
|
def capture_stderr(line: str):
|
||||||
|
stderr_lines.append(line)
|
||||||
|
|
||||||
|
# No debug mode enabled
|
||||||
|
options = ClaudeCodeOptions(stderr=capture_stderr)
|
||||||
|
|
||||||
|
# Run a simple query
|
||||||
|
async for _ in query(prompt="What is 1+1?", options=options):
|
||||||
|
pass # Just consume messages
|
||||||
|
|
||||||
|
# Should work but capture minimal/no output without debug
|
||||||
|
assert len(stderr_lines) == 0, "Should not capture stderr output without debug mode"
|
||||||
44
examples/stderr_callback_example.py
Normal file
44
examples/stderr_callback_example.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Simple example demonstrating stderr callback for capturing CLI debug output."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from claude_code_sdk import ClaudeCodeOptions, query
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Capture stderr output from the CLI using a callback."""
|
||||||
|
|
||||||
|
# Collect stderr messages
|
||||||
|
stderr_messages = []
|
||||||
|
|
||||||
|
def stderr_callback(message: str):
|
||||||
|
"""Callback that receives each line of stderr output."""
|
||||||
|
stderr_messages.append(message)
|
||||||
|
# Optionally print specific messages
|
||||||
|
if "[ERROR]" in message:
|
||||||
|
print(f"Error detected: {message}")
|
||||||
|
|
||||||
|
# Create options with stderr callback and enable debug mode
|
||||||
|
options = ClaudeCodeOptions(
|
||||||
|
stderr=stderr_callback,
|
||||||
|
extra_args={"debug-to-stderr": None} # Enable debug output
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run a query
|
||||||
|
print("Running query with stderr capture...")
|
||||||
|
async for message in query(
|
||||||
|
prompt="What is 2+2?",
|
||||||
|
options=options
|
||||||
|
):
|
||||||
|
if hasattr(message, 'content'):
|
||||||
|
if isinstance(message.content, str):
|
||||||
|
print(f"Response: {message.content}")
|
||||||
|
|
||||||
|
# Show what we captured
|
||||||
|
print(f"\nCaptured {len(stderr_messages)} stderr lines")
|
||||||
|
if stderr_messages:
|
||||||
|
print("First stderr line:", stderr_messages[0][:100])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -12,6 +12,7 @@ from subprocess import PIPE
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
import anyio.abc
|
||||||
from anyio.abc import Process
|
from anyio.abc import Process
|
||||||
from anyio.streams.text import TextReceiveStream, TextSendStream
|
from anyio.streams.text import TextReceiveStream, TextSendStream
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ class SubprocessCLITransport(Transport):
|
|||||||
self._process: Process | None = None
|
self._process: Process | None = None
|
||||||
self._stdout_stream: TextReceiveStream | None = None
|
self._stdout_stream: TextReceiveStream | None = None
|
||||||
self._stdin_stream: TextSendStream | None = None
|
self._stdin_stream: TextSendStream | None = None
|
||||||
|
self._stderr_stream: TextReceiveStream | None = None
|
||||||
|
self._stderr_task_group: anyio.abc.TaskGroup | None = None
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._exit_error: Exception | None = None # Track process exit errors
|
self._exit_error: Exception | None = None # Track process exit errors
|
||||||
|
|
||||||
@@ -216,14 +219,15 @@ class SubprocessCLITransport(Transport):
|
|||||||
if self._cwd:
|
if self._cwd:
|
||||||
process_env["PWD"] = self._cwd
|
process_env["PWD"] = self._cwd
|
||||||
|
|
||||||
# Only output stderr if customer explicitly requested debug output and provided a file object
|
# Pipe stderr if we have a callback OR debug mode is enabled
|
||||||
stderr_dest = (
|
should_pipe_stderr = (
|
||||||
self._options.debug_stderr
|
self._options.stderr is not None
|
||||||
if "debug-to-stderr" in self._options.extra_args
|
or "debug-to-stderr" in self._options.extra_args
|
||||||
and self._options.debug_stderr
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For backward compat: use debug_stderr file object if no callback and debug is on
|
||||||
|
stderr_dest = PIPE if should_pipe_stderr else None
|
||||||
|
|
||||||
self._process = await anyio.open_process(
|
self._process = await anyio.open_process(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=PIPE,
|
stdin=PIPE,
|
||||||
@@ -237,6 +241,14 @@ class SubprocessCLITransport(Transport):
|
|||||||
if self._process.stdout:
|
if self._process.stdout:
|
||||||
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
self._stdout_stream = TextReceiveStream(self._process.stdout)
|
||||||
|
|
||||||
|
# Setup stderr stream if piped
|
||||||
|
if should_pipe_stderr and self._process.stderr:
|
||||||
|
self._stderr_stream = TextReceiveStream(self._process.stderr)
|
||||||
|
# Start async task to read stderr
|
||||||
|
self._stderr_task_group = anyio.create_task_group()
|
||||||
|
await self._stderr_task_group.__aenter__()
|
||||||
|
self._stderr_task_group.start_soon(self._handle_stderr)
|
||||||
|
|
||||||
# Setup stdin for streaming mode
|
# Setup stdin for streaming mode
|
||||||
if self._is_streaming and self._process.stdin:
|
if self._is_streaming and self._process.stdin:
|
||||||
self._stdin_stream = TextSendStream(self._process.stdin)
|
self._stdin_stream = TextSendStream(self._process.stdin)
|
||||||
@@ -262,6 +274,34 @@ class SubprocessCLITransport(Transport):
|
|||||||
self._exit_error = error
|
self._exit_error = error
|
||||||
raise error from e
|
raise error from e
|
||||||
|
|
||||||
|
async def _handle_stderr(self) -> None:
|
||||||
|
"""Handle stderr stream - read and invoke callbacks."""
|
||||||
|
if not self._stderr_stream:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for line in self._stderr_stream:
|
||||||
|
line_str = line.rstrip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Call the stderr callback if provided
|
||||||
|
if self._options.stderr:
|
||||||
|
self._options.stderr(line_str)
|
||||||
|
|
||||||
|
# For backward compatibility: write to debug_stderr if in debug mode
|
||||||
|
elif (
|
||||||
|
"debug-to-stderr" in self._options.extra_args
|
||||||
|
and self._options.debug_stderr
|
||||||
|
):
|
||||||
|
self._options.debug_stderr.write(line_str + "\n")
|
||||||
|
if hasattr(self._options.debug_stderr, "flush"):
|
||||||
|
self._options.debug_stderr.flush()
|
||||||
|
except anyio.ClosedResourceError:
|
||||||
|
pass # Stream closed, exit normally
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore other errors during stderr reading
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the transport and clean up resources."""
|
"""Close the transport and clean up resources."""
|
||||||
self._ready = False
|
self._ready = False
|
||||||
@@ -269,12 +309,24 @@ class SubprocessCLITransport(Transport):
|
|||||||
if not self._process:
|
if not self._process:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Close stderr task group if active
|
||||||
|
if self._stderr_task_group:
|
||||||
|
with suppress(Exception):
|
||||||
|
self._stderr_task_group.cancel_scope.cancel()
|
||||||
|
await self._stderr_task_group.__aexit__(None, None, None)
|
||||||
|
self._stderr_task_group = None
|
||||||
|
|
||||||
# Close streams
|
# Close streams
|
||||||
if self._stdin_stream:
|
if self._stdin_stream:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self._stdin_stream.aclose()
|
await self._stdin_stream.aclose()
|
||||||
self._stdin_stream = None
|
self._stdin_stream = None
|
||||||
|
|
||||||
|
if self._stderr_stream:
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._stderr_stream.aclose()
|
||||||
|
self._stderr_stream = None
|
||||||
|
|
||||||
if self._process.stdin:
|
if self._process.stdin:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await self._process.stdin.aclose()
|
await self._process.stdin.aclose()
|
||||||
@@ -291,6 +343,7 @@ class SubprocessCLITransport(Transport):
|
|||||||
self._process = None
|
self._process = None
|
||||||
self._stdout_stream = None
|
self._stdout_stream = None
|
||||||
self._stdin_stream = None
|
self._stdin_stream = None
|
||||||
|
self._stderr_stream = None
|
||||||
self._exit_error = None
|
self._exit_error = None
|
||||||
|
|
||||||
async def write(self, data: str) -> None:
|
async def write(self, data: str) -> None:
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ class ClaudeAgentOptions:
|
|||||||
) # Pass arbitrary CLI flags
|
) # Pass arbitrary CLI flags
|
||||||
debug_stderr: Any = (
|
debug_stderr: Any = (
|
||||||
sys.stderr
|
sys.stderr
|
||||||
) # File-like object for debug output when debug-to-stderr is set
|
) # Deprecated: File-like object for debug output. Use stderr callback instead.
|
||||||
|
stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI
|
||||||
|
|
||||||
# Tool permission callback
|
# Tool permission callback
|
||||||
can_use_tool: CanUseTool | None = None
|
can_use_tool: CanUseTool | None = None
|
||||||
|
|||||||
Reference in New Issue
Block a user