From 33ae03518088a5831cb6ee8fc3ad137ad25345db Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 16:49:06 -0500 Subject: [PATCH 01/27] fix: enable tool execution when enable_tools=true Previously, setting enable_tools=true in the request body would log "Tools enabled by user request" but tools would not actually execute. The Claude Agent SDK requires both: 1. allowed_tools to be set (specifying which tools can be used) 2. permission_mode to be set to "bypassPermissions" for headless/API usage This fix: - Sets allowed_tools to DEFAULT_ALLOWED_TOOLS (Read, Glob, Grep, Bash, Write, Edit) - Sets permission_mode to "bypassPermissions" when tools are enabled - Adds permission_mode parameter passthrough to ClaudeCodeCLI.run_completion() - Fixes parse_claude_message() to return ResultMessage.result for multi-turn conversations (previously it returned only the first AssistantMessage text) Tested with file read/write operations - tools now execute correctly. Authored by: Aaron Lippold --- src/claude_cli.py | 27 ++++++++++++++++++++++----- src/main.py | 16 +++++++++++++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/claude_cli.py b/src/claude_cli.py index da5c648..d87057e 100644 --- a/src/claude_cli.py +++ b/src/claude_cli.py @@ -103,6 +103,7 @@ async def run_completion( disallowed_tools: Optional[List[str]] = None, session_id: Optional[str] = None, continue_session: bool = False, + permission_mode: Optional[str] = None, ) -> AsyncGenerator[Dict[str, Any], None]: """Run Claude Agent using the Python SDK and yield response chunks.""" @@ -136,6 +137,10 @@ async def run_completion( if disallowed_tools: options.disallowed_tools = disallowed_tools + # Set permission mode (needed for tool execution in API context) + if permission_mode: + options.permission_mode = permission_mode + # Handle session continuity if continue_session: options.continue_session = True @@ -188,7 +193,18 @@ async def run_completion( } def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: - """Extract the assistant message from Claude Agent SDK messages.""" + """Extract the assistant message from Claude Agent SDK messages. + + Prioritizes ResultMessage.result for multi-turn conversations, + falls back to last AssistantMessage content. + """ + # First, check for ResultMessage with 'result' field (multi-turn completion) + for message in messages: + if message.get("subtype") == "success" and "result" in message: + return message["result"] + + # Collect all text from AssistantMessages (take the last one with text) + last_text = None for message in messages: # Look for AssistantMessage type (new SDK format) if "content" in message and isinstance(message["content"], list): @@ -203,7 +219,7 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: text_parts.append(block) if text_parts: - return "\n".join(text_parts) + last_text = "\n".join(text_parts) # Fallback: look for old format elif message.get("type") == "assistant" and "message" in message: @@ -216,11 +232,12 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: for block in content: if isinstance(block, dict) and block.get("type") == "text": text_parts.append(block.get("text", "")) - return "\n".join(text_parts) if text_parts else None + if text_parts: + last_text = "\n".join(text_parts) elif isinstance(content, str): - return content + last_text = content - return None + return last_text def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: """Extract metadata like costs, tokens, and session info from SDK messages.""" diff --git a/src/main.py b/src/main.py index dba2405..2a0c169 100644 --- a/src/main.py +++ b/src/main.py @@ -45,7 +45,7 @@ rate_limit_exceeded_handler, rate_limit_endpoint, ) -from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS +from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS # Load environment variables load_dotenv() @@ -385,7 +385,11 @@ async def generate_streaming_response( claude_options["max_turns"] = 1 # Single turn for Q&A logger.info("Tools disabled (default behavior for OpenAI compatibility)") else: - logger.info("Tools enabled by user request") + # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) + claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + claude_options["permission_mode"] = "bypassPermissions" + logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") # Run Claude Code chunks_buffer = [] @@ -399,6 +403,7 @@ async def generate_streaming_response( max_turns=claude_options.get("max_turns", 10), allowed_tools=claude_options.get("allowed_tools"), disallowed_tools=claude_options.get("disallowed_tools"), + permission_mode=claude_options.get("permission_mode"), stream=True, ): chunks_buffer.append(chunk) @@ -642,7 +647,11 @@ async def chat_completions( claude_options["max_turns"] = 1 # Single turn for Q&A logger.info("Tools disabled (default behavior for OpenAI compatibility)") else: - logger.info("Tools enabled by user request") + # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) + claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + claude_options["permission_mode"] = "bypassPermissions" + logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") # Collect all chunks chunks = [] @@ -653,6 +662,7 @@ async def chat_completions( max_turns=claude_options.get("max_turns", 10), allowed_tools=claude_options.get("allowed_tools"), disallowed_tools=claude_options.get("disallowed_tools"), + permission_mode=claude_options.get("permission_mode"), stream=False, ): chunks.append(chunk) From 5c15d9e20a5e7d6805d07c6db15baf71efbc15d5 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 16:56:10 -0500 Subject: [PATCH 02/27] test: add unit tests for tool execution and fix integration test skipping - Add tests/test_tool_execution.py with unit tests for: - permission_mode option support in ClaudeAgentOptions - DEFAULT_ALLOWED_TOOLS constant validation - parse_claude_message() handling of ResultMessage and multi-turn conversations - ClaudeCodeCLI.run_completion() permission_mode parameter - Add tests/conftest.py with requires_server marker for integration tests - Add @requires_server decorator to all integration tests that need a running server - Fix test_sdk_quick.py async test with proper pytest.mark.asyncio decorator - Add skip conditions for tests requiring ANTHROPIC_API_KEY This ensures pytest runs cleanly (32 passed, 24 skipped) without needing a running server, while integration tests can still be run manually. Authored by: Aaron Lippold --- tests/conftest.py | 23 +++++ tests/test_basic.py | 89 ++++++++-------- tests/test_endpoints.py | 50 ++++++--- tests/test_non_streaming.py | 60 +++++------ tests/test_parameter_mapping.py | 107 ++++++++++--------- tests/test_sdk_quick.py | 30 ++++-- tests/test_session_complete.py | 171 ++++++++++++++++++------------- tests/test_session_continuity.py | 154 ++++++++++++++++------------ tests/test_session_simple.py | 83 +++++++++------ tests/test_tool_execution.py | 158 ++++++++++++++++++++++++++++ 10 files changed, 606 insertions(+), 319 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_tool_execution.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d5ab386 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +""" +Pytest configuration and fixtures for claude-code-openai-wrapper tests. +""" + +import pytest +import requests + + +# Check if server is running for integration tests +def is_server_running(base_url: str = "http://localhost:8000") -> bool: + """Check if the test server is running.""" + try: + response = requests.get(f"{base_url}/health", timeout=2) + return response.status_code == 200 + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + return False + + +# Marker for tests that require a running server +requires_server = pytest.mark.skipif( + not is_server_running(), + reason="Server not running at localhost:8000. Start with: poetry run python main.py", +) diff --git a/tests/test_basic.py b/tests/test_basic.py index 9808483..9d127ba 100755 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -6,22 +6,26 @@ import sys import os +import pytest import requests + +from tests.conftest import requires_server from openai import OpenAI + def get_api_key(): """Get the appropriate API key for testing.""" # Check if user provided API key via environment if os.getenv("TEST_API_KEY"): return os.getenv("TEST_API_KEY") - + # Check server auth status try: response = requests.get("http://localhost:8000/v1/auth/status") if response.status_code == 200: auth_data = response.json() server_info = auth_data.get("server_info", {}) - + if not server_info.get("api_key_required", False): # No auth required, use a dummy key return "no-auth-required" @@ -34,9 +38,11 @@ def get_api_key(): except Exception as e: print(f"⚠️ Could not check server auth status: {e}") print(" Assuming no authentication required") - + return "fallback-dummy-key" + +@requires_server def test_health_check(): """Test the health endpoint.""" print("Testing health check...") @@ -52,6 +58,8 @@ def test_health_check(): print(f"✗ Cannot connect to server: {e}") return False + +@requires_server def test_models_endpoint(): """Test the models endpoint.""" print("\nTesting models endpoint...") @@ -68,28 +76,25 @@ def test_models_endpoint(): print(f"✗ Models endpoint error: {e}") return False + +@requires_server def test_openai_sdk(): """Test with OpenAI SDK.""" print("\nTesting OpenAI SDK integration...") - + api_key = get_api_key() if api_key is None: print("✗ Cannot run test - API key required but not provided") return False - + try: - client = OpenAI( - base_url="http://localhost:8000/v1", - api_key=api_key - ) - + client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key) + # Simple test - use a model supported by Claude Agent SDK response = client.chat.completions.create( model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK - messages=[ - {"role": "user", "content": "Say 'Hello, World!' and nothing else."} - ], - max_tokens=50 + messages=[{"role": "user", "content": "Say 'Hello, World!' and nothing else."}], + max_tokens=50, ) content = response.choices[0].message.content @@ -103,41 +108,38 @@ def test_openai_sdk(): print(f"✓ OpenAI SDK test passed") print(f" Response: {content}") return True - + except Exception as e: print(f"✗ OpenAI SDK test failed: {e}") return False + +@requires_server def test_streaming(): """Test streaming functionality.""" print("\nTesting streaming...") - + api_key = get_api_key() if api_key is None: print("✗ Cannot run test - API key required but not provided") return False - + try: - client = OpenAI( - base_url="http://localhost:8000/v1", - api_key=api_key - ) - + client = OpenAI(base_url="http://localhost:8000/v1", api_key=api_key) + stream = client.chat.completions.create( model="claude-sonnet-4-5-20250929", # Use newer model supported by SDK - messages=[ - {"role": "user", "content": "Count from 1 to 3."} - ], - stream=True + messages=[{"role": "user", "content": "Count from 1 to 3."}], + stream=True, ) - + chunks_received = 0 content = "" for chunk in stream: chunks_received += 1 if chunk.choices[0].delta.content: content += chunk.choices[0].delta.content - + if chunks_received > 0: print(f"✓ Streaming test passed ({chunks_received} chunks)") print(f" Response: {content[:50]}...") @@ -145,18 +147,19 @@ def test_streaming(): else: print("✗ No streaming chunks received") return False - + except Exception as e: print(f"✗ Streaming test failed: {e}") return False + def main(): """Run all tests.""" print("Claude Code OpenAI Wrapper - Basic Tests") - print("="*50) + print("=" * 50) print("Make sure the server is running: python main.py") - print("="*50) - + print("=" * 50) + # Show API key status api_key = get_api_key() if api_key: @@ -166,23 +169,18 @@ def main(): print("🔑 Server authentication: Required (using provided key)") else: print("❌ Server authentication: Required but no key available") - print("="*50) - - tests = [ - test_health_check, - test_models_endpoint, - test_openai_sdk, - test_streaming - ] - + print("=" * 50) + + tests = [test_health_check, test_models_endpoint, test_openai_sdk, test_streaming] + passed = 0 for test in tests: if test(): passed += 1 - - print("\n" + "="*50) + + print("\n" + "=" * 50) print(f"Tests completed: {passed}/{len(tests)} passed") - + if passed == len(tests): print("✓ All tests passed! The wrapper is working correctly.") return 0 @@ -190,5 +188,6 @@ def main(): print("✗ Some tests failed. Check the server logs for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6799285..3592818 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -4,11 +4,16 @@ Run this while the server is running on localhost:8000 """ +import pytest import requests + +from tests.conftest import requires_server import json BASE_URL = "http://localhost:8000" + +@requires_server def test_health(): print("Testing /health endpoint...") try: @@ -20,6 +25,8 @@ def test_health(): print(f" Error: {e}") return False + +@requires_server def test_auth_status(): print("\nTesting /v1/auth/status endpoint...") try: @@ -31,6 +38,8 @@ def test_auth_status(): print(f" Error: {e}") return False + +@requires_server def test_models(): print("\nTesting /v1/models endpoint...") try: @@ -38,74 +47,81 @@ def test_models(): print(f" Status: {response.status_code}") models = response.json() print(f" Found {len(models.get('data', []))} models") - for model in models.get('data', [])[:3]: # Show first 3 + for model in models.get("data", [])[:3]: # Show first 3 print(f" - {model.get('id')}") return response.status_code == 200 except Exception as e: print(f" Error: {e}") return False + +@requires_server def test_chat_completion(): print("\nTesting /v1/chat/completions endpoint...") try: payload = { "model": "claude-3-5-haiku-20241022", # Use fastest model "messages": [ - {"role": "user", "content": "Say 'Hello, SDK integration working!' and nothing else."} + { + "role": "user", + "content": "Say 'Hello, SDK integration working!' and nothing else.", + } ], - "max_tokens": 50 + "max_tokens": 50, } - + response = requests.post( f"{BASE_URL}/v1/chat/completions", json=payload, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) - + print(f" Status: {response.status_code}") - + if response.status_code == 200: result = response.json() - content = result.get('choices', [{}])[0].get('message', {}).get('content', '') + content = result.get("choices", [{}])[0].get("message", {}).get("content", "") print(f" Response: {content}") print(f" Usage: {result.get('usage', {})}") return True else: print(f" Error: {response.text}") return False - + except Exception as e: print(f" Error: {e}") return False + def main(): print("Claude Code OpenAI Wrapper - Endpoint Tests") print("=" * 50) - + tests = [ ("Health Check", test_health), - ("Auth Status", test_auth_status), + ("Auth Status", test_auth_status), ("Models List", test_models), - ("Chat Completion", test_chat_completion) + ("Chat Completion", test_chat_completion), ] - + passed = 0 total = len(tests) - + for name, test_func in tests: if test_func(): print(f"✓ {name} passed") passed += 1 else: print(f"✗ {name} failed") - + print("=" * 50) print(f"Results: {passed}/{total} tests passed") - + if passed == total: print("🎉 All tests passed! SDK integration is working correctly.") else: print("❌ Some tests failed. Check server logs for details.") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_non_streaming.py b/tests/test_non_streaming.py index 61170c0..ec94673 100644 --- a/tests/test_non_streaming.py +++ b/tests/test_non_streaming.py @@ -5,60 +5,58 @@ import os import json +import pytest import requests +from tests.conftest import requires_server + # Set debug mode -os.environ['DEBUG_MODE'] = 'true' +os.environ["DEBUG_MODE"] = "true" + +@requires_server def test_non_streaming(): """Test that non-streaming responses work correctly.""" print("🧪 Testing non-streaming response...") - + # Simple request with streaming disabled request_data = { "model": "claude-3-7-sonnet-20250219", - "messages": [ - { - "role": "user", - "content": "What is 2+2?" - } - ], + "messages": [{"role": "user", "content": "What is 2+2?"}], "stream": False, - "temperature": 0.0 + "temperature": 0.0, } - + try: # Send non-streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", - json=request_data, - timeout=30 + "http://localhost:8000/v1/chat/completions", json=request_data, timeout=30 ) - + print(f"✅ Response status: {response.status_code}") - + if response.status_code != 200: print(f"❌ Request failed: {response.text}") return False - + # Parse response data = response.json() - + # Check response structure - if 'choices' in data and len(data['choices']) > 0: - message = data['choices'][0]['message'] - content = message['content'] - + if "choices" in data and len(data["choices"]) > 0: + message = data["choices"][0]["message"] + content = message["content"] + print(f"📊 Response content: {content}") - + # Check if we got actual content instead of fallback message fallback_messages = [ "I'm unable to provide a response at the moment", - "I understand you're testing the system" + "I understand you're testing the system", ] - + is_fallback = any(msg in content for msg in fallback_messages) - + if not is_fallback and len(content) > 0: print("\n🎉 Non-streaming response is working!") print("✅ Real content extracted successfully") @@ -70,18 +68,19 @@ def test_non_streaming(): else: print("❌ Unexpected response structure") return False - + except Exception as e: print(f"❌ Test failed with exception: {e}") return False + def main(): """Test non-streaming responses.""" print("🔍 Testing Non-Streaming Responses") print("=" * 50) - + success = test_non_streaming() - + print("\n" + "=" * 50) if success: print("🎉 Non-streaming test PASSED!") @@ -89,9 +88,10 @@ def main(): else: print("❌ Non-streaming test FAILED") print("⚠️ Issue may still persist") - + return success + if __name__ == "__main__": success = main() - exit(0 if success else 1) \ No newline at end of file + exit(0 if success else 1) diff --git a/tests/test_parameter_mapping.py b/tests/test_parameter_mapping.py index e27a7f6..d6bcaa2 100644 --- a/tests/test_parameter_mapping.py +++ b/tests/test_parameter_mapping.py @@ -1,33 +1,41 @@ #!/usr/bin/env python3 """ Test script demonstrating OpenAI to Claude Code SDK parameter mapping. + +These are integration tests that require a running server. +Run with: poetry run pytest tests/test_parameter_mapping.py -v """ import asyncio import json +import pytest import requests from typing import Dict, Any +from tests.conftest import requires_server + # Test server URL BASE_URL = "http://localhost:8000" + +@requires_server def test_basic_completion(): """Test basic chat completion with OpenAI parameters.""" print("=== Testing Basic Completion ===") - + payload = { "model": "claude-3-5-sonnet-20241022", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Say hello in a creative way."} + {"role": "user", "content": "Say hello in a creative way."}, ], "temperature": 0.7, # Will be ignored with warning - "max_tokens": 100, # Will be ignored with warning - "stream": False + "max_tokens": 100, # Will be ignored with warning + "stream": False, } - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) - + if response.status_code == 200: print("✅ Request successful") result = response.json() @@ -36,31 +44,27 @@ def test_basic_completion(): print(f"❌ Request failed: {response.status_code}") print(response.text) + +@requires_server def test_with_claude_headers(): """Test completion with Claude-specific headers.""" print("\n=== Testing with Claude-Specific Headers ===") - + payload = { - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "List the files in the current directory"} - ], - "stream": False + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "List the files in the current directory"}], + "stream": False, } - + headers = { "Content-Type": "application/json", "X-Claude-Max-Turns": "5", "X-Claude-Allowed-Tools": "ls,pwd,cat", - "X-Claude-Permission-Mode": "acceptEdits" + "X-Claude-Permission-Mode": "acceptEdits", } - - response = requests.post( - f"{BASE_URL}/v1/chat/completions", - json=payload, - headers=headers - ) - + + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, headers=headers) + if response.status_code == 200: print("✅ Request with Claude headers successful") result = response.json() @@ -69,10 +73,12 @@ def test_with_claude_headers(): print(f"❌ Request failed: {response.status_code}") print(response.text) + +@requires_server def test_compatibility_check(): """Test the compatibility endpoint.""" print("\n=== Testing Compatibility Check ===") - + payload = { "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello"}], @@ -84,11 +90,11 @@ def test_compatibility_check(): "logit_bias": {"hello": 2.0}, "stop": ["END"], "n": 1, - "user": "test_user" + "user": "test_user", } - + response = requests.post(f"{BASE_URL}/v1/compatibility", json=payload) - + if response.status_code == 200: print("✅ Compatibility check successful") result = response.json() @@ -97,54 +103,51 @@ def test_compatibility_check(): print(f"❌ Compatibility check failed: {response.status_code}") print(response.text) + +@requires_server def test_parameter_validation(): """Test parameter validation (should fail).""" print("\n=== Testing Parameter Validation ===") - + # Test with n > 1 (should fail) payload = { "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello"}], - "n": 3 # Should fail validation + "n": 3, # Should fail validation } - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) - + if response.status_code == 422: print("✅ Validation correctly rejected n > 1") print(response.json()) else: print(f"❌ Expected validation error, got: {response.status_code}") + def test_streaming_with_parameters(): """Test streaming response with unsupported parameters.""" print("\n=== Testing Streaming with Unsupported Parameters ===") - + payload = { "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Write a short poem about programming"} - ], + "messages": [{"role": "user", "content": "Write a short poem about programming"}], "temperature": 0.9, # Will be warned about - "max_tokens": 200, # Will be warned about - "stream": True + "max_tokens": 200, # Will be warned about + "stream": True, } - + try: - response = requests.post( - f"{BASE_URL}/v1/chat/completions", - json=payload, - stream=True - ) - + response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, stream=True) + if response.status_code == 200: print("✅ Streaming request successful") print("First few chunks:") count = 0 for line in response.iter_lines(): if line and count < 5: - line_str = line.decode('utf-8') - if line_str.startswith('data: ') and not line_str.endswith('[DONE]'): + line_str = line.decode("utf-8") + if line_str.startswith("data: ") and not line_str.endswith("[DONE]"): print(f" {line_str}") count += 1 else: @@ -152,11 +155,12 @@ def test_streaming_with_parameters(): except Exception as e: print(f"❌ Streaming test error: {e}") + def main(): """Run all tests.""" print("OpenAI to Claude Code SDK Parameter Mapping Tests") print("=" * 50) - + try: # Check if server is running response = requests.get(f"{BASE_URL}/health") @@ -164,23 +168,26 @@ def main(): print("❌ Server is not running. Start it with: poetry run python main.py") return print("✅ Server is running") - + # Run tests test_basic_completion() test_with_claude_headers() test_compatibility_check() test_parameter_validation() test_streaming_with_parameters() - + print("\n" + "=" * 50) print("🎉 All tests completed!") print("\nTo see parameter warnings in detail, run the server with:") - print("PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\"") - + print( + "PYTHONPATH=. poetry run python -c \"import logging; logging.basicConfig(level=logging.DEBUG); exec(open('main.py').read())\"" + ) + except requests.exceptions.ConnectionError: print("❌ Cannot connect to server. Make sure it's running on port 8000") except Exception as e: print(f"❌ Test error: {e}") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_sdk_quick.py b/tests/test_sdk_quick.py index 5f43734..fd6b1cb 100644 --- a/tests/test_sdk_quick.py +++ b/tests/test_sdk_quick.py @@ -1,15 +1,28 @@ #!/usr/bin/env python3 -"""Quick test of Claude Agent SDK to verify migration.""" +"""Quick test of Claude Agent SDK to verify migration. + +This is an integration test that calls the Claude API. +""" import asyncio import sys import os +import pytest + # Ensure we're in the right directory sys.path.insert(0, os.path.dirname(__file__)) from claude_agent_sdk import query, ClaudeAgentOptions +# Skip if no API key available (integration test) +pytestmark = pytest.mark.skipif( + not os.getenv("ANTHROPIC_API_KEY"), + reason="ANTHROPIC_API_KEY not set - skipping SDK integration test", +) + + +@pytest.mark.asyncio async def test_simple_query(): """Test a simple query with the new SDK.""" print("Testing Claude Agent SDK with simple query...") @@ -20,21 +33,24 @@ async def test_simple_query(): async for message in query( prompt="Say 'Hello!' and nothing else.", options=ClaudeAgentOptions( - max_turns=1, - model="claude-3-5-haiku-20241022" # Fastest model for testing - ) + max_turns=1, model="claude-3-5-haiku-20241022" # Fastest model for testing + ), ): messages.append(message) print(f"Got message type: {type(message)}") # Try to extract content - if hasattr(message, 'content'): + if hasattr(message, "content"): print(f"Content: {message.content}") elif isinstance(message, dict): print(f"Message dict: {message}") # Break early if we get an assistant message - msg_type = getattr(message, 'type', None) if hasattr(message, 'type') else message.get("type") if isinstance(message, dict) else None + msg_type = ( + getattr(message, "type", None) + if hasattr(message, "type") + else message.get("type") if isinstance(message, dict) else None + ) if msg_type == "assistant": print("✓ Got assistant response!") break @@ -50,9 +66,11 @@ async def test_simple_query(): except Exception as e: print(f"✗ Test failed with error: {e}") import traceback + traceback.print_exc() return False + if __name__ == "__main__": result = asyncio.run(test_simple_query()) sys.exit(0 if result else 1) diff --git a/tests/test_session_complete.py b/tests/test_session_complete.py index d929673..425aeb4 100644 --- a/tests/test_session_complete.py +++ b/tests/test_session_complete.py @@ -3,18 +3,23 @@ Comprehensive test for session continuity functionality. """ +import pytest import requests + +from tests.conftest import requires_server import json import time BASE_URL = "http://localhost:8000" + +@requires_server def test_session_continuity_comprehensive(): """Test session continuity with multiple conversation turns.""" print("🧪 Testing comprehensive session continuity...") - + session_id = "comprehensive-test" - + # Conversation sequence to test memory conversation = [ {"user": "Hello! My name is Charlie and I'm 25 years old.", "expect_memory": None}, @@ -23,137 +28,163 @@ def test_session_continuity_comprehensive(): {"user": "How old am I?", "expect_memory": "25"}, {"user": "What do I do for work?", "expect_memory": "software engineer"}, ] - + for i, turn in enumerate(conversation, 1): print(f"\n{i}️⃣ Turn {i}: {turn['user']}") - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": turn["user"]}], - "session_id": session_id - }) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": turn["user"]}], + "session_id": session_id, + }, + ) + if response.status_code != 200: print(f"❌ Turn {i} failed: {response.status_code}") return False - + result = response.json() - response_text = result['choices'][0]['message']['content'] + response_text = result["choices"][0]["message"]["content"] print(f" Response: {response_text[:100]}...") - + # Check if expected information is remembered if turn["expect_memory"]: if turn["expect_memory"].lower() in response_text.lower(): print(f" ✅ Memory check passed: '{turn['expect_memory']}' found") else: - print(f" ⚠️ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working") - + print( + f" ⚠️ Memory check unclear: '{turn['expect_memory']}' not found, but may still be working" + ) + # Check session info session_info = requests.get(f"{BASE_URL}/v1/sessions/{session_id}") if session_info.status_code == 200: info = session_info.json() print(f"\n📊 Session info: {info['message_count']} messages stored") expected_messages = len(conversation) * 2 # user + assistant for each turn - if info['message_count'] == expected_messages: + if info["message_count"] == expected_messages: print(f" ✅ Correct message count: {expected_messages}") else: - print(f" ⚠️ Message count mismatch: expected {expected_messages}, got {info['message_count']}") - + print( + f" ⚠️ Message count mismatch: expected {expected_messages}, got {info['message_count']}" + ) + # Cleanup requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") print(f" 🧹 Session {session_id} cleaned up") - + return True + +@requires_server def test_stateless_vs_session(): """Test that stateless and session modes work differently.""" print("\n🧪 Testing stateless vs session behavior...") - + # Test stateless (no session_id) print("1️⃣ Stateless mode:") - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}] - }) - + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}], + }, + ) + # Follow up question without session_id - response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "What's my favorite color?"}] - }) - + response1 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + }, + ) + if response1.status_code == 200: result1 = response1.json() - stateless_response = result1['choices'][0]['message']['content'] + stateless_response = result1["choices"][0]["message"]["content"] print(f" Stateless response: {stateless_response[:100]}...") - - # Test session mode + + # Test session mode print("2️⃣ Session mode:") session_id = "color-test-session" - - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Remember: my favorite color is red."}], - "session_id": session_id - }) - - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "What's my favorite color?"}], - "session_id": session_id - }) - + + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Remember: my favorite color is red."}], + "session_id": session_id, + }, + ) + + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + "session_id": session_id, + }, + ) + if response2.status_code == 200: result2 = response2.json() - session_response = result2['choices'][0]['message']['content'] + session_response = result2["choices"][0]["message"]["content"] print(f" Session response: {session_response[:100]}...") - + if "red" in session_response.lower(): print(" ✅ Session mode correctly remembered the color") else: print(" ⚠️ Session mode didn't clearly show memory, but may still be working") - + # Cleanup requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") return True + +@requires_server def test_session_endpoints(): """Test all session management endpoints.""" print("\n🧪 Testing session management endpoints...") - + # Create some sessions session_ids = ["endpoint-test-1", "endpoint-test-2", "endpoint-test-3"] - + for session_id in session_ids: - requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": f"Test session {session_id}"}], - "session_id": session_id - }) - + requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": f"Test session {session_id}"}], + "session_id": session_id, + }, + ) + # Test list sessions list_response = requests.get(f"{BASE_URL}/v1/sessions") if list_response.status_code == 200: sessions = list_response.json() print(f" ✅ Listed {sessions['total']} sessions") - - if sessions['total'] >= len(session_ids): + + if sessions["total"] >= len(session_ids): print(f" ✅ Found all test sessions") else: print(f" ⚠️ Expected at least {len(session_ids)} sessions, found {sessions['total']}") - + # Test get specific session get_response = requests.get(f"{BASE_URL}/v1/sessions/{session_ids[0]}") if get_response.status_code == 200: session_info = get_response.json() print(f" ✅ Retrieved session info: {session_info['message_count']} messages") - + # Test session stats stats_response = requests.get(f"{BASE_URL}/v1/sessions/stats") if stats_response.status_code == 200: stats = stats_response.json() print(f" ✅ Session stats: {stats['session_stats']['active_sessions']} active") - + # Test delete sessions for session_id in session_ids: delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") @@ -161,13 +192,14 @@ def test_session_endpoints(): print(f" ✅ Deleted session {session_id}") else: print(f" ❌ Failed to delete session {session_id}") - + return True + def main(): """Run comprehensive session tests.""" print("🚀 Starting comprehensive session continuity tests...") - + # Test server health try: health = requests.get(f"{BASE_URL}/health", timeout=5) @@ -178,14 +210,14 @@ def main(): except Exception as e: print(f"❌ Server connection error: {e}") return - + # Run all tests tests = [ ("Session Continuity", test_session_continuity_comprehensive), ("Stateless vs Session", test_stateless_vs_session), ("Session Endpoints", test_session_endpoints), ] - + passed = 0 for test_name, test_func in tests: try: @@ -197,15 +229,16 @@ def main(): print(f"❌ {test_name} test failed") except Exception as e: print(f"❌ {test_name} test error: {e}") - + print(f"\n{'='*50}") print(f"📊 Final Results: {passed}/{len(tests)} tests passed") - + if passed == len(tests): print("🎉 All comprehensive session tests passed!") print("✨ Session continuity is working correctly!") else: print("⚠️ Some tests failed - check the output above") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py index 887f44a..26bb143 100644 --- a/tests/test_session_continuity.py +++ b/tests/test_session_continuity.py @@ -5,7 +5,10 @@ import asyncio import json +import pytest import requests + +from tests.conftest import requires_server import time from typing import Dict, Any @@ -14,17 +17,19 @@ TEST_SESSION_ID = "test-session-123" +@requires_server def test_stateless_mode(): """Test traditional stateless OpenAI-style requests.""" print("🧪 Testing stateless mode...") - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! My name is Alice."} - ] - }) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello! My name is Alice."}], + }, + ) + if response.status_code == 200: result = response.json() print(f"✅ Stateless request successful") @@ -35,48 +40,51 @@ def test_stateless_mode(): return False +@requires_server def test_session_mode(): """Test session-based requests with conversation continuity.""" print(f"\n🧪 Testing session mode with session_id: {TEST_SESSION_ID}") - + # First message in session print("1️⃣ First message in session...") - response1 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! My name is Bob. Remember this name."} - ], - "session_id": TEST_SESSION_ID - }) - + response1 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello! My name is Bob. Remember this name."}], + "session_id": TEST_SESSION_ID, + }, + ) + if response1.status_code != 200: print(f"❌ First session request failed: {response1.status_code} - {response1.text}") return False - + result1 = response1.json() print(f"✅ First session message successful") print(f" Response: {result1['choices'][0]['message']['content'][:100]}...") - + # Second message in same session - should remember the name print("2️⃣ Second message in same session...") - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my name?"} - ], - "session_id": TEST_SESSION_ID - }) - + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my name?"}], + "session_id": TEST_SESSION_ID, + }, + ) + if response2.status_code != 200: print(f"❌ Second session request failed: {response2.status_code} - {response2.text}") return False - + result2 = response2.json() print(f"✅ Second session message successful") print(f" Response: {result2['choices'][0]['message']['content'][:100]}...") - + # Check if the response mentions the name "Bob" - response_text = result2['choices'][0]['message']['content'].lower() + response_text = result2["choices"][0]["message"]["content"].lower() if "bob" in response_text: print("✅ Session continuity working - Claude remembered the name!") return True @@ -85,22 +93,23 @@ def test_session_mode(): return True # Still successful, maybe Claude responded differently +@requires_server def test_session_management_endpoints(): """Test session management endpoints.""" print(f"\n🧪 Testing session management endpoints...") - + # List sessions print("1️⃣ Listing sessions...") response = requests.get(f"{BASE_URL}/v1/sessions") if response.status_code == 200: sessions = response.json() print(f"✅ Sessions listed: {sessions['total']} active sessions") - if sessions['total'] > 0: + if sessions["total"] > 0: print(f" First session: {sessions['sessions'][0]['session_id']}") else: print(f"❌ Failed to list sessions: {response.status_code}") return False - + # Get specific session info print("2️⃣ Getting session info...") response = requests.get(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}") @@ -112,7 +121,7 @@ def test_session_management_endpoints(): else: print(f"❌ Failed to get session info: {response.status_code}") return False - + # Get session stats print("3️⃣ Getting session stats...") response = requests.get(f"{BASE_URL}/v1/sessions/stats") @@ -124,49 +133,58 @@ def test_session_management_endpoints(): else: print(f"❌ Failed to get session stats: {response.status_code}") return False - + return True +@requires_server def test_session_streaming(): """Test session continuity with streaming.""" print(f"\n🧪 Testing session streaming...") - + # Create a new session for streaming test stream_session_id = "test-stream-456" - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello! I'm testing streaming. My favorite color is purple."} - ], - "session_id": stream_session_id, - "stream": True - }, stream=True) - + + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": "Hello! I'm testing streaming. My favorite color is purple.", + } + ], + "session_id": stream_session_id, + "stream": True, + }, + stream=True, + ) + if response.status_code != 200: print(f"❌ Streaming request failed: {response.status_code}") return False - + print("✅ Streaming response received") - + # Follow up with another message in the same session time.sleep(1) # Give time for the session to be updated - - response2 = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my favorite color?"} - ], - "session_id": stream_session_id - }) - + + response2 = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my favorite color?"}], + "session_id": stream_session_id, + }, + ) + if response2.status_code == 200: result = response2.json() - response_text = result['choices'][0]['message']['content'].lower() + response_text = result["choices"][0]["message"]["content"].lower() print(f"✅ Follow-up message successful") print(f" Response: {result['choices'][0]['message']['content'][:100]}...") - + if "purple" in response_text: print("✅ Session continuity working with streaming!") else: @@ -180,7 +198,7 @@ def test_session_streaming(): def cleanup_test_sessions(): """Clean up test sessions.""" print(f"\n🧹 Cleaning up test sessions...") - + for session_id in [TEST_SESSION_ID, "test-stream-456"]: response = requests.delete(f"{BASE_URL}/v1/sessions/{session_id}") if response.status_code == 200: @@ -195,7 +213,7 @@ def main(): """Run all session continuity tests.""" print("🚀 Starting session continuity tests...") print(f" Server: {BASE_URL}") - + # Test server health first try: response = requests.get(f"{BASE_URL}/health", timeout=5) @@ -207,10 +225,10 @@ def main(): print(f"❌ Cannot connect to server: {e}") print(" Make sure the server is running with: poetry run python main.py") return - + success_count = 0 total_tests = 4 - + # Run tests tests = [ ("Stateless Mode", test_stateless_mode), @@ -218,7 +236,7 @@ def main(): ("Session Management", test_session_management_endpoints), ("Session Streaming", test_session_streaming), ] - + for test_name, test_func in tests: try: if test_func(): @@ -227,13 +245,13 @@ def main(): print(f"❌ {test_name} test failed") except Exception as e: print(f"❌ {test_name} test error: {e}") - + # Cleanup cleanup_test_sessions() - + # Results print(f"\n📊 Test Results: {success_count}/{total_tests} tests passed") - + if success_count == total_tests: print("🎉 All session continuity tests passed!") else: @@ -241,4 +259,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_session_simple.py b/tests/test_session_simple.py index 3c3c2f4..0ddb224 100644 --- a/tests/test_session_simple.py +++ b/tests/test_session_simple.py @@ -1,42 +1,50 @@ #!/usr/bin/env python3 """ Simple test for session continuity functionality. + +These are integration tests that require a running server. """ +import pytest import requests import json import time +from tests.conftest import requires_server + BASE_URL = "http://localhost:8000" TEST_SESSION_ID = "test-simple-session" + +@requires_server def test_session_creation(): """Test creating a session and checking it appears in the list.""" print("🧪 Testing session creation...") - + # Make a request with a session_id - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "Hello, remember my name is Alice."} - ], - "session_id": TEST_SESSION_ID - }) - + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "Hello, remember my name is Alice."}], + "session_id": TEST_SESSION_ID, + }, + ) + if response.status_code != 200: print(f"❌ Session creation failed: {response.status_code}") return False - + print("✅ Session creation request successful") - + # Check if session appears in the list sessions_response = requests.get(f"{BASE_URL}/v1/sessions") if sessions_response.status_code == 200: sessions_data = sessions_response.json() print(f"✅ Found {sessions_data['total']} sessions") - + # Check if our session is in the list - session_ids = [s['session_id'] for s in sessions_data['sessions']] + session_ids = [s["session_id"] for s in sessions_data["sessions"]] if TEST_SESSION_ID in session_ids: print(f"✅ Session {TEST_SESSION_ID} found in session list") return True @@ -47,27 +55,30 @@ def test_session_creation(): print(f"❌ Failed to list sessions: {sessions_response.status_code}") return False + +@requires_server def test_session_continuity(): """Test that conversation context is maintained across requests.""" print("\n🧪 Testing session continuity...") - + # Follow up message asking about the name - response = requests.post(f"{BASE_URL}/v1/chat/completions", json={ - "model": "claude-3-5-sonnet-20241022", - "messages": [ - {"role": "user", "content": "What's my name?"} - ], - "session_id": TEST_SESSION_ID - }) - + response = requests.post( + f"{BASE_URL}/v1/chat/completions", + json={ + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "What's my name?"}], + "session_id": TEST_SESSION_ID, + }, + ) + if response.status_code != 200: print(f"❌ Continuity test failed: {response.status_code}") return False - + result = response.json() - response_text = result['choices'][0]['message']['content'].lower() + response_text = result["choices"][0]["message"]["content"].lower() print(f"Response: {result['choices'][0]['message']['content'][:100]}...") - + # Check if response mentions Alice if "alice" in response_text: print("✅ Session continuity working - name remembered!") @@ -76,20 +87,22 @@ def test_session_continuity(): print("⚠️ Response doesn't mention Alice, but session continuity may still be working") return True # Don't fail the test just because of this + +@requires_server def test_session_cleanup(): """Test session deletion.""" print("\n🧪 Testing session cleanup...") - + # Delete the session delete_response = requests.delete(f"{BASE_URL}/v1/sessions/{TEST_SESSION_ID}") if delete_response.status_code == 200: print("✅ Session deleted successfully") - + # Verify it's gone from the list sessions_response = requests.get(f"{BASE_URL}/v1/sessions") if sessions_response.status_code == 200: sessions_data = sessions_response.json() - session_ids = [s['session_id'] for s in sessions_data['sessions']] + session_ids = [s["session_id"] for s in sessions_data["sessions"]] if TEST_SESSION_ID not in session_ids: print("✅ Session successfully removed from list") return True @@ -103,10 +116,11 @@ def test_session_cleanup(): print(f"❌ Failed to delete session: {delete_response.status_code}") return False + def main(): """Run simple session tests.""" print("🚀 Starting simple session tests...") - + # Test server health try: health_response = requests.get(f"{BASE_URL}/health", timeout=5) @@ -117,14 +131,14 @@ def main(): except Exception as e: print(f"❌ Cannot connect to server: {e}") return - + # Run tests tests = [ ("Session Creation", test_session_creation), ("Session Continuity", test_session_continuity), ("Session Cleanup", test_session_cleanup), ] - + passed = 0 for test_name, test_func in tests: try: @@ -134,13 +148,14 @@ def main(): print(f"❌ {test_name} test failed") except Exception as e: print(f"❌ {test_name} test error: {e}") - + print(f"\n📊 Results: {passed}/{len(tests)} tests passed") - + if passed == len(tests): print("🎉 All session tests passed!") else: print("⚠️ Some tests failed") + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_tool_execution.py b/tests/test_tool_execution.py new file mode 100644 index 0000000..3c8fe34 --- /dev/null +++ b/tests/test_tool_execution.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Tests for tool execution functionality. + +Tests the fixes for enable_tools=true parameter: +- permission_mode passthrough to ClaudeAgentOptions +- parse_claude_message correctly handling multi-turn ResultMessage +- DEFAULT_ALLOWED_TOOLS configuration +""" + +import pytest +from claude_agent_sdk import ClaudeAgentOptions + + +class TestPermissionMode: + """Test permission_mode configuration for tool execution.""" + + def test_permission_mode_option_exists(self): + """Test that ClaudeAgentOptions supports permission_mode.""" + options = ClaudeAgentOptions(max_turns=1, permission_mode="bypassPermissions") + assert options.permission_mode == "bypassPermissions" + + def test_permission_mode_default(self): + """Test that permission_mode defaults to None/default.""" + options = ClaudeAgentOptions(max_turns=1) + # permission_mode should be None or "default" when not set + assert options.permission_mode in [None, "default", ""] + + def test_permission_mode_accept_edits(self): + """Test acceptEdits permission mode.""" + options = ClaudeAgentOptions(max_turns=1, permission_mode="acceptEdits") + assert options.permission_mode == "acceptEdits" + + +class TestDefaultAllowedTools: + """Test DEFAULT_ALLOWED_TOOLS constant.""" + + def test_default_allowed_tools_defined(self): + """Test that DEFAULT_ALLOWED_TOOLS is defined.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + assert isinstance(DEFAULT_ALLOWED_TOOLS, list) + assert len(DEFAULT_ALLOWED_TOOLS) > 0 + + def test_default_allowed_tools_contains_safe_tools(self): + """Test that DEFAULT_ALLOWED_TOOLS contains expected safe tools.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + # These tools should be in the default allowed set + expected_tools = ["Read", "Glob", "Grep", "Bash", "Write", "Edit"] + for tool in expected_tools: + assert tool in DEFAULT_ALLOWED_TOOLS, f"Expected {tool} in DEFAULT_ALLOWED_TOOLS" + + def test_default_allowed_tools_excludes_dangerous(self): + """Test that potentially dangerous tools are excluded by default.""" + from src.constants import DEFAULT_ALLOWED_TOOLS + + # These tools should NOT be in the default allowed set + # (they're in DEFAULT_DISALLOWED_TOOLS) + dangerous_tools = ["Task", "WebFetch", "WebSearch"] + for tool in dangerous_tools: + assert ( + tool not in DEFAULT_ALLOWED_TOOLS + ), f"{tool} should not be in DEFAULT_ALLOWED_TOOLS" + + +class TestParseClaudeMessage: + """Test parse_claude_message correctly handles multi-turn conversations.""" + + def test_result_message_priority(self): + """Test that ResultMessage.result is prioritized over AssistantMessage.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + # Simulate multi-turn conversation messages + messages = [ + # First assistant message (initial response) + { + "content": [type("TextBlock", (), {"text": "I'll list the files."})()], + }, + # Tool use message (not text) + { + "content": [type("ToolUseBlock", (), {"name": "Bash", "input": {}})()], + }, + # Final result message with full answer + { + "subtype": "success", + "result": "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt", + }, + ] + + result = cli.parse_claude_message(messages) + + # Should return the ResultMessage.result, not the first AssistantMessage + assert result == "The files are:\n1. file1.txt\n2. file2.txt\n3. file3.txt" + + def test_fallback_to_last_assistant_message(self): + """Test fallback to last AssistantMessage when no ResultMessage.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + # Simulate messages without ResultMessage + messages = [ + { + "content": [type("TextBlock", (), {"text": "First response"})()], + }, + { + "content": [type("TextBlock", (), {"text": "Second response"})()], + }, + ] + + result = cli.parse_claude_message(messages) + + # Should return the LAST text, not the first + assert result == "Second response" + + def test_handles_empty_messages(self): + """Test handling of empty message list.""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + result = cli.parse_claude_message([]) + assert result is None + + def test_handles_dict_content_blocks(self): + """Test handling of dict-based content blocks (old format).""" + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd="/tmp") + + messages = [{"content": [{"type": "text", "text": "Hello world"}]}] + + result = cli.parse_claude_message(messages) + assert result == "Hello world" + + +class TestClaudeCliPermissionMode: + """Test that ClaudeCodeCLI passes permission_mode correctly.""" + + def test_run_completion_accepts_permission_mode(self): + """Test that run_completion method accepts permission_mode parameter.""" + from src.claude_cli import ClaudeCodeCLI + import inspect + + # Check that permission_mode is in the method signature + sig = inspect.signature(ClaudeCodeCLI.run_completion) + param_names = list(sig.parameters.keys()) + + assert ( + "permission_mode" in param_names + ), "run_completion should accept permission_mode parameter" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From cd28bf1b65ac8121dc53d282a8decadd8c8ddfda Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 17:48:34 -0500 Subject: [PATCH 03/27] feat: add Anthropic Messages API compatible endpoint (/v1/messages) Add support for native Anthropic SDK clients by implementing the /v1/messages endpoint. This enables tools like VC to use this wrapper via custom base URL configuration (e.g., VC_API_BASE=http://localhost:8000). Changes: - Add AnthropicMessagesRequest/Response models in models.py - Add /v1/messages POST endpoint in main.py - Add comprehensive tests in test_anthropic_messages.py - Tools enabled by default for Anthropic SDK clients (agentic workflows) Authored by: Aaron Lippold --- src/main.py | 101 +++++++++++++++ src/models.py | 70 ++++++++++ tests/test_anthropic_messages.py | 215 +++++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 tests/test_anthropic_messages.py diff --git a/src/main.py b/src/main.py index 2a0c169..0477a54 100644 --- a/src/main.py +++ b/src/main.py @@ -32,6 +32,11 @@ MCPServerInfoResponse, MCPServersListResponse, MCPConnectionRequest, + # Anthropic API compatible models + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicTextBlock, + AnthropicUsage, ) from src.claude_cli import ClaudeCodeCLI from src.message_adapter import MessageAdapter @@ -712,6 +717,102 @@ async def chat_completions( raise HTTPException(status_code=500, detail=str(e)) +@app.post("/v1/messages") +@rate_limit_endpoint("chat") +async def anthropic_messages( + request_body: AnthropicMessagesRequest, + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Anthropic Messages API compatible endpoint. + + This endpoint provides compatibility with the native Anthropic SDK, + allowing tools like VC to use this wrapper via the VC_API_BASE setting. + """ + # Check FastAPI API key if configured + await verify_api_key(request, credentials) + + # Validate Claude Code authentication + auth_valid, auth_info = validate_claude_code_auth() + + if not auth_valid: + error_detail = { + "message": "Claude Code authentication failed", + "errors": auth_info.get("errors", []), + "method": auth_info.get("method", "none"), + "help": "Check /v1/auth/status for detailed authentication information", + } + raise HTTPException(status_code=503, detail=error_detail) + + try: + logger.info(f"Anthropic Messages API request: model={request_body.model}") + + # Convert Anthropic messages to internal format + messages = request_body.to_openai_messages() + + # Build prompt from messages + prompt_parts = [] + for msg in messages: + if msg.role == "user": + prompt_parts.append(msg.content) + elif msg.role == "assistant": + prompt_parts.append(f"Assistant: {msg.content}") + + prompt = "\n\n".join(prompt_parts) + system_prompt = request_body.system + + # Filter content + prompt = MessageAdapter.filter_content(prompt) + if system_prompt: + system_prompt = MessageAdapter.filter_content(system_prompt) + + # Run Claude Code - tools enabled by default for Anthropic SDK clients + # (they're typically using this for agentic workflows) + chunks = [] + async for chunk in claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + model=request_body.model, + max_turns=10, + allowed_tools=DEFAULT_ALLOWED_TOOLS, + permission_mode="bypassPermissions", + stream=False, + ): + chunks.append(chunk) + + # Extract assistant message + raw_assistant_content = claude_cli.parse_claude_message(chunks) + + if not raw_assistant_content: + raise HTTPException(status_code=500, detail="No response from Claude Code") + + # Filter out tool usage and thinking blocks + assistant_content = MessageAdapter.filter_content(raw_assistant_content) + + # Estimate tokens + prompt_tokens = MessageAdapter.estimate_tokens(prompt) + completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + + # Create Anthropic-format response + response = AnthropicMessagesResponse( + model=request_body.model, + content=[AnthropicTextBlock(text=assistant_content)], + stop_reason="end_turn", + usage=AnthropicUsage( + input_tokens=prompt_tokens, + output_tokens=completion_tokens, + ), + ) + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Anthropic Messages API error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/v1/models") async def list_models( request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) diff --git a/src/models.py b/src/models.py index 3738481..82e85f4 100644 --- a/src/models.py +++ b/src/models.py @@ -6,10 +6,12 @@ logger = logging.getLogger(__name__) + # Import DEFAULT_MODEL to avoid circular imports def get_default_model(): """Get default model from constants to avoid circular imports.""" from src.constants import DEFAULT_MODEL + return DEFAULT_MODEL @@ -407,3 +409,71 @@ def validate_tool_name(cls, v: str) -> str: if len(v) > 200: raise ValueError("Tool name too long (max 200 characters)") return v.strip() + + +# ============================================================================ +# Anthropic API Compatible Models (for /v1/messages endpoint) +# ============================================================================ + + +class AnthropicTextBlock(BaseModel): + """Anthropic text content block.""" + + type: Literal["text"] = "text" + text: str + + +class AnthropicMessage(BaseModel): + """Anthropic message format.""" + + role: Literal["user", "assistant"] + content: Union[str, List[AnthropicTextBlock]] + + +class AnthropicMessagesRequest(BaseModel): + """Anthropic Messages API request format.""" + + model: str + messages: List[AnthropicMessage] + max_tokens: int = Field(default=4096, description="Maximum tokens to generate") + system: Optional[str] = Field(default=None, description="System prompt") + temperature: Optional[float] = Field(default=1.0, ge=0, le=1) + top_p: Optional[float] = Field(default=None, ge=0, le=1) + top_k: Optional[int] = Field(default=None, ge=0) + stop_sequences: Optional[List[str]] = None + stream: Optional[bool] = False + metadata: Optional[Dict[str, Any]] = None + + def to_openai_messages(self) -> List[Message]: + """Convert Anthropic messages to OpenAI format.""" + result = [] + for msg in self.messages: + content = msg.content + if isinstance(content, list): + # Extract text from content blocks + text_parts = [ + block.text for block in content if isinstance(block, AnthropicTextBlock) + ] + content = "\n".join(text_parts) + result.append(Message(role=msg.role, content=content)) + return result + + +class AnthropicUsage(BaseModel): + """Anthropic usage information.""" + + input_tokens: int + output_tokens: int + + +class AnthropicMessagesResponse(BaseModel): + """Anthropic Messages API response format.""" + + id: str = Field(default_factory=lambda: f"msg_{uuid.uuid4().hex[:24]}") + type: Literal["message"] = "message" + role: Literal["assistant"] = "assistant" + content: List[AnthropicTextBlock] + model: str + stop_reason: Optional[Literal["end_turn", "max_tokens", "stop_sequence"]] = "end_turn" + stop_sequence: Optional[str] = None + usage: AnthropicUsage diff --git a/tests/test_anthropic_messages.py b/tests/test_anthropic_messages.py new file mode 100644 index 0000000..1f8d303 --- /dev/null +++ b/tests/test_anthropic_messages.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Tests for the Anthropic Messages API compatible endpoint (/v1/messages). + +This endpoint provides compatibility with the native Anthropic SDK, +enabling tools like VC to use this wrapper via custom base URL configuration. +""" + +import pytest +import requests + +from tests.conftest import requires_server + +BASE_URL = "http://localhost:8000" + + +class TestAnthropicMessagesModels: + """Test Anthropic API model classes.""" + + def test_anthropic_text_block(self): + """Test AnthropicTextBlock model.""" + from src.models import AnthropicTextBlock + + block = AnthropicTextBlock(text="Hello world") + assert block.type == "text" + assert block.text == "Hello world" + + def test_anthropic_message(self): + """Test AnthropicMessage model.""" + from src.models import AnthropicMessage + + # String content + msg = AnthropicMessage(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + + # List content + from src.models import AnthropicTextBlock + + msg2 = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")]) + assert msg2.role == "assistant" + assert len(msg2.content) == 1 + + def test_anthropic_messages_request(self): + """Test AnthropicMessagesRequest model.""" + from src.models import AnthropicMessagesRequest, AnthropicMessage + + request = AnthropicMessagesRequest( + model="claude-sonnet-4-5-20250929", + messages=[AnthropicMessage(role="user", content="Hello")], + max_tokens=100, + system="You are helpful", + ) + + assert request.model == "claude-sonnet-4-5-20250929" + assert len(request.messages) == 1 + assert request.max_tokens == 100 + assert request.system == "You are helpful" + + def test_anthropic_messages_request_to_openai(self): + """Test conversion from Anthropic to OpenAI message format.""" + from src.models import AnthropicMessagesRequest, AnthropicMessage + + request = AnthropicMessagesRequest( + model="claude-sonnet-4-5-20250929", + messages=[ + AnthropicMessage(role="user", content="Hello"), + AnthropicMessage(role="assistant", content="Hi there"), + AnthropicMessage(role="user", content="How are you?"), + ], + ) + + openai_messages = request.to_openai_messages() + assert len(openai_messages) == 3 + assert openai_messages[0].role == "user" + assert openai_messages[0].content == "Hello" + assert openai_messages[1].role == "assistant" + assert openai_messages[2].content == "How are you?" + + def test_anthropic_messages_response(self): + """Test AnthropicMessagesResponse model.""" + from src.models import ( + AnthropicMessagesResponse, + AnthropicTextBlock, + AnthropicUsage, + ) + + response = AnthropicMessagesResponse( + model="claude-sonnet-4-5-20250929", + content=[AnthropicTextBlock(text="Hello!")], + usage=AnthropicUsage(input_tokens=10, output_tokens=5), + ) + + assert response.type == "message" + assert response.role == "assistant" + assert response.model == "claude-sonnet-4-5-20250929" + assert len(response.content) == 1 + assert response.content[0].text == "Hello!" + assert response.stop_reason == "end_turn" + assert response.usage.input_tokens == 10 + assert response.usage.output_tokens == 5 + + +class TestAnthropicMessagesEndpoint: + """Integration tests for /v1/messages endpoint.""" + + @requires_server + def test_basic_message(self): + """Test basic message request.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "messages": [{"role": "user", "content": "Say 'test' and nothing else"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + + # Verify Anthropic response format + assert result["type"] == "message" + assert result["role"] == "assistant" + assert "content" in result + assert len(result["content"]) > 0 + assert result["content"][0]["type"] == "text" + assert "usage" in result + assert "input_tokens" in result["usage"] + assert "output_tokens" in result["usage"] + + @requires_server + def test_message_with_system_prompt(self): + """Test message with system prompt.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "system": "You always respond with exactly one word.", + "messages": [{"role": "user", "content": "Say hello"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["type"] == "message" + assert len(result["content"]) > 0 + + @requires_server + def test_multi_turn_conversation(self): + """Test multi-turn conversation.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "My name is Alice."}, + {"role": "assistant", "content": "Hello Alice!"}, + {"role": "user", "content": "What's my name?"}, + ], + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["type"] == "message" + # The response should reference Alice + response_text = result["content"][0]["text"].lower() + assert "alice" in response_text + + @requires_server + def test_invalid_request_missing_messages(self): + """Test error handling for missing messages.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + # Missing 'messages' field + }, + ) + + assert response.status_code == 422 # Validation error + + @requires_server + def test_response_format_matches_anthropic_sdk(self): + """Test that response format matches what Anthropic SDK expects.""" + response = requests.post( + f"{BASE_URL}/v1/messages", + json={ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 50, + "messages": [{"role": "user", "content": "Hi"}], + }, + ) + + assert response.status_code == 200 + result = response.json() + + # Required fields for Anthropic SDK compatibility + assert "id" in result + assert result["id"].startswith("msg_") + assert result["type"] == "message" + assert result["role"] == "assistant" + assert isinstance(result["content"], list) + assert result["stop_reason"] in ["end_turn", "max_tokens", "stop_sequence"] + assert "usage" in result + assert "input_tokens" in result["usage"] + assert "output_tokens" in result["usage"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From d8c6f59a39fadbc8b3f5640aef69abcaa1c782f6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 21:57:30 -0500 Subject: [PATCH 04/27] feat: add CLAUDE_AUTH_METHOD env var and styled landing page - Add CLAUDE_AUTH_METHOD to explicitly select auth (cli, api_key, bedrock, vertex) - Fixes conflict when ANTHROPIC_API_KEY is set but wrapper should use CLI auth - Add styled landing page at root with Tailwind CSS - Shows auth status, endpoints, quick start, and config options Authored by: Aaron Lippold --- .env.example | 5 ++ src/auth.py | 27 +++++++- src/main.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 6cddda2..6f6ed72 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Claude CLI Configuration CLAUDE_CLI_PATH=claude +# Authentication Method (optional - explicit selection) +# Set this to override auto-detection. Values: cli, api_key, bedrock, vertex +# If not set, auto-detects based on available env vars (ANTHROPIC_API_KEY, etc.) +# CLAUDE_AUTH_METHOD=cli + # API Configuration # If API_KEY is not set, server will prompt for interactive API key protection on startup # Leave commented out to enable interactive prompt, or uncomment to use a fixed API key diff --git a/src/auth.py b/src/auth.py index 2a9f43f..b3fec27 100644 --- a/src/auth.py +++ b/src/auth.py @@ -32,7 +32,32 @@ def get_api_key(self): return self.env_api_key def _detect_auth_method(self) -> str: - """Detect which Claude Code authentication method is configured.""" + """Detect which Claude Code authentication method is configured. + + Priority: + 1. Explicit CLAUDE_AUTH_METHOD env var (cli, api_key, bedrock, vertex) + 2. Legacy env vars (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) + 3. Auto-detect based on ANTHROPIC_API_KEY presence + 4. Default to claude_cli + """ + # Check for explicit auth method first + explicit_method = os.getenv("CLAUDE_AUTH_METHOD", "").lower() + if explicit_method: + method_map = { + "cli": "claude_cli", + "claude_cli": "claude_cli", + "api_key": "anthropic", + "anthropic": "anthropic", + "bedrock": "bedrock", + "vertex": "vertex", + } + if explicit_method in method_map: + logger.info(f"Using explicit auth method: {method_map[explicit_method]}") + return method_map[explicit_method] + else: + logger.warning(f"Unknown CLAUDE_AUTH_METHOD '{explicit_method}', falling back to auto-detect") + + # Fall back to legacy env vars and auto-detection if os.getenv("CLAUDE_CODE_USE_BEDROCK") == "1": return "bedrock" elif os.getenv("CLAUDE_CODE_USE_VERTEX") == "1": diff --git a/src/main.py b/src/main.py index 0477a54..d152da9 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, HTTPException, Request, Depends from fastapi.security import HTTPAuthorizationCredentials from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, JSONResponse +from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse from fastapi.exceptions import RequestValidationError from pydantic import ValidationError from dotenv import load_dotenv @@ -868,6 +868,178 @@ async def health_check(request: Request): return {"status": "healthy", "service": "claude-code-openai-wrapper"} +@app.get("/", response_class=HTMLResponse) +async def root(): + """Landing page with API documentation.""" + auth_info = get_claude_code_auth_info() + auth_method = auth_info.get("method", "unknown") + auth_valid = auth_info.get("status", {}).get("valid", False) + status_color = "green" if auth_valid else "red" + status_text = "Connected" if auth_valid else "Not Connected" + + html_content = f""" + + + + + + Claude Code OpenAI Wrapper + + + + + +
+ +
+
+
+ + + +
+
+
+

Claude Code OpenAI Wrapper

+

OpenAI-compatible API for Claude

+
+
+ + +
+
+
+
+ {status_text} +
+
+ Auth: {auth_method} +
+
+
+ + +
+

+ + + + Quick Start +

+
+ $ curl http://localhost:8000/v1/chat/completions \\
+   -H "Content-Type: application/json" \\
+   -d '{{"model": "claude-sonnet-4-5-20250929", "messages": [{{"role": "user", "content": "Hello!"}}]}}' +
+
+ + +
+

+ + + + API Endpoints +

+
+
+ POST + /v1/chat/completions + OpenAI-compatible chat +
+
+ POST + /v1/messages + Anthropic-compatible +
+
+ GET + /v1/models + List models +
+
+ GET + /v1/auth/status + Auth status +
+
+ GET + /v1/sessions + Active sessions +
+
+ GET + /health + Health check +
+
+
+ + +
+

+ + + + + Configuration +

+

Set CLAUDE_AUTH_METHOD to choose authentication:

+
+
+ cli +

Claude CLI auth

+
+
+ api_key +

ANTHROPIC_API_KEY

+
+
+ bedrock +

AWS Bedrock

+
+
+ vertex +

Google Vertex AI

+
+
+
+ + + +
+ + + """ + return HTMLResponse(content=html_content) + + @app.post("/v1/debug/request") @rate_limit_endpoint("debug") async def debug_request_validation(request: Request): From fc7e3f542f16f964d5f94b8f2e01c08f680fddf6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 23:31:51 -0500 Subject: [PATCH 05/27] feat: improve landing page with light/dark mode, clickable endpoints, version - Add light/dark mode toggle with localStorage persistence - Make GET endpoints clickable with arrow icons - Add version badge (v2.1.0) in header - Add GitHub icon linking to repository - Add /version endpoint returning version info - Update __version__ to 2.1.0 matching pyproject.toml - Full light mode support with dark: Tailwind prefixes Authored by: Aaron Lippold --- src/__init__.py | 2 +- src/main.py | 206 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 147 insertions(+), 61 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index 1d29cf2..e2b4f2d 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "1.0.0" +__version__ = "2.1.0" diff --git a/src/main.py b/src/main.py index d152da9..383fa33 100644 --- a/src/main.py +++ b/src/main.py @@ -868,9 +868,24 @@ async def health_check(request: Request): return {"status": "healthy", "service": "claude-code-openai-wrapper"} +@app.get("/version") +@rate_limit_endpoint("health") +async def version_info(request: Request): + """Version information endpoint.""" + from src import __version__ + + return { + "version": __version__, + "service": "claude-code-openai-wrapper", + "api_version": "v1", + } + + @app.get("/", response_class=HTMLResponse) async def root(): """Landing page with API documentation.""" + from src import __version__ + auth_info = get_claude_code_auth_info() auth_method = auth_info.get("method", "unknown") auth_valid = auth_info.get("status", {}).get("valid", False) @@ -899,120 +914,191 @@ async def root(): + - +
-
-
-
- - - +
+
+
+
+ + + +
+
+
+

Claude Code OpenAI Wrapper

+

OpenAI-compatible API for Claude

-
-

Claude Code OpenAI Wrapper

-

OpenAI-compatible API for Claude

+
+ + v{__version__} + + + + + + + +
-
+
- {status_text} + {status_text}
-
- Auth: {auth_method} +
+ Auth: {auth_method}
-
-

- +
+

+ Quick Start

-
- $ curl http://localhost:8000/v1/chat/completions \\
-   -H "Content-Type: application/json" \\
-   -d '{{"model": "claude-sonnet-4-5-20250929", "messages": [{{"role": "user", "content": "Hello!"}}]}}' +
+ $ curl http://localhost:8000/v1/chat/completions \\
+   -H "Content-Type: application/json" \\
+   -d '{{"model": "claude-sonnet-4-5-20250929", "messages": [{{"role": "user", "content": "Hello!"}}]}}'
-
-

- +
+

+ API Endpoints

- -
-

- +
+

+ Configuration

-

Set CLAUDE_AUTH_METHOD to choose authentication:

+

Set CLAUDE_AUTH_METHOD to choose authentication:

-
- cli +
+ cli

Claude CLI auth

-
- api_key +
+ api_key

ANTHROPIC_API_KEY

-
- bedrock +
+ bedrock

AWS Bedrock

-
- vertex +
+ vertex

Google Vertex AI

@@ -1020,13 +1106,13 @@ async def root():
- + API Docs - + From 1534a1f19a0742eecfd9db561bd3ae861e1bf465 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Sun, 28 Dec 2025 23:36:20 -0500 Subject: [PATCH 06/27] feat: add accordion endpoints with live JSON preview and syntax highlighting - GET endpoints now expand on click to show live API response - JSON responses are pretty-printed with 2-space indentation - Syntax highlighting: keys (green), strings (amber), numbers (blue), booleans (purple), null (gray) - Lazy loading: data fetched only when accordion opened - Chevron icon rotates to indicate open/closed state - POST endpoints remain static (no accordion) Authored by: Aaron Lippold --- src/main.py | 182 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 148 insertions(+), 34 deletions(-) diff --git a/src/main.py b/src/main.py index 383fa33..2a9a7a1 100644 --- a/src/main.py +++ b/src/main.py @@ -914,10 +914,77 @@ async def root(): + - + - -
+ +
-
-
-
- + -
-
-
-
- {status_text} -
-
- Auth: {auth_method} +
+
+
+ + {status_text}
+ Auth: {auth_method}
-
+ -
-

- - - - Quick Start -

-
-
-
- - -
""" From 1d60cddf29d9ff81f49305fe81cdd3c4075d1802 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 09:36:00 -0500 Subject: [PATCH 20/27] ci: add GitHub Actions workflow for tests and linting - Run tests on Python 3.10, 3.11, 3.12 - Black linting check - Coverage reporting with Codecov - Caching for Poetry dependencies Authored by: Aaron Lippold --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..24c6ed7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Install project + run: poetry install --no-interaction + + - name: Run linting + run: poetry run black --check src tests + + - name: Run tests + run: poetry run pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false From 395bdfa406f0b4a0e5fc10125753d7e95976fbe6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 09:39:14 -0500 Subject: [PATCH 21/27] style: apply black formatting Authored by: Aaron Lippold --- src/auth.py | 4 +- src/constants.py | 2 +- tests/test_auth_unit.py | 39 ++++---------- tests/test_claude_cli_unit.py | 44 +++++++++------- tests/test_message_adapter_unit.py | 6 +-- tests/test_models_unit.py | 32 +++--------- tests/test_sdk_migration.py | 26 ++-------- tests/test_session_manager_unit.py | 4 +- tests/test_textblock_fix.py | 81 ++++++++++++++---------------- tests/test_working_directory.py | 77 ++++++++++++++-------------- 10 files changed, 135 insertions(+), 180 deletions(-) diff --git a/src/auth.py b/src/auth.py index b3fec27..7b23e69 100644 --- a/src/auth.py +++ b/src/auth.py @@ -55,7 +55,9 @@ def _detect_auth_method(self) -> str: logger.info(f"Using explicit auth method: {method_map[explicit_method]}") return method_map[explicit_method] else: - logger.warning(f"Unknown CLAUDE_AUTH_METHOD '{explicit_method}', falling back to auto-detect") + logger.warning( + f"Unknown CLAUDE_AUTH_METHOD '{explicit_method}', falling back to auto-detect" + ) # Fall back to legacy env vars and auto-detection if os.getenv("CLAUDE_CODE_USE_BEDROCK") == "1": diff --git a/src/constants.py b/src/constants.py index ed4ebfa..1bf914f 100644 --- a/src/constants.py +++ b/src/constants.py @@ -49,7 +49,7 @@ # NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x CLAUDE_MODELS = [ # Claude 4.5 Family (Latest - Fall 2025) - RECOMMENDED - "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable + "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable "claude-sonnet-4-5-20250929", # Recommended - best coding model "claude-haiku-4-5-20251001", # Fast & cheap # Claude 4.1 diff --git a/tests/test_auth_unit.py b/tests/test_auth_unit.py index 65ef565..ba9ec92 100644 --- a/tests/test_auth_unit.py +++ b/tests/test_auth_unit.py @@ -76,9 +76,7 @@ def test_explicit_vertex_method(self): def test_unknown_method_falls_back(self): """Unknown CLAUDE_AUTH_METHOD falls back to auto-detect.""" - with patch.dict( - os.environ, {"CLAUDE_AUTH_METHOD": "unknown_method"}, clear=False - ): + with patch.dict(os.environ, {"CLAUDE_AUTH_METHOD": "unknown_method"}, clear=False): import src.auth importlib.reload(src.auth) @@ -94,9 +92,7 @@ def test_legacy_bedrock_env_var(self): """CLAUDE_CODE_USE_BEDROCK=1 uses bedrock.""" env = {"CLAUDE_CODE_USE_BEDROCK": "1"} # Remove CLAUDE_AUTH_METHOD if present - env_copy = { - k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD" - } + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} env_copy.update(env) with patch.dict(os.environ, env_copy, clear=True): import src.auth @@ -107,9 +103,7 @@ def test_legacy_bedrock_env_var(self): def test_legacy_vertex_env_var(self): """CLAUDE_CODE_USE_VERTEX=1 uses vertex.""" env = {"CLAUDE_CODE_USE_VERTEX": "1"} - env_copy = { - k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD" - } + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} env_copy.update(env) with patch.dict(os.environ, env_copy, clear=True): import src.auth @@ -123,8 +117,7 @@ def test_auto_detect_anthropic_key(self): env_copy = { k: v for k, v in os.environ.items() - if k - not in ["CLAUDE_AUTH_METHOD", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"] + if k not in ["CLAUDE_AUTH_METHOD", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"] } env_copy.update(env) with patch.dict(os.environ, env_copy, clear=True): @@ -269,9 +262,7 @@ def test_validate_vertex_missing_config(self): def test_validate_claude_cli_always_valid(self): """Claude CLI auth is always considered valid initially.""" - env_copy = { - k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD" - } + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} env_copy["CLAUDE_AUTH_METHOD"] = "cli" with patch.dict(os.environ, env_copy, clear=True): import src.auth @@ -343,9 +334,7 @@ def test_vertex_env_vars(self): def test_cli_env_vars_empty(self): """CLI method returns no environment variables.""" - env_copy = { - k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD" - } + env_copy = {k: v for k, v in os.environ.items() if k != "CLAUDE_AUTH_METHOD"} env_copy["CLAUDE_AUTH_METHOD"] = "cli" with patch.dict(os.environ, env_copy, clear=True): import src.auth @@ -388,9 +377,7 @@ async def test_valid_api_key_passes(self): scheme="Bearer", credentials="test-secret-key" ) - with patch.object( - src.auth.auth_manager, "get_api_key", return_value="test-secret-key" - ): + with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-secret-key"): result = await src.auth.verify_api_key(mock_request, credentials) assert result is True @@ -405,13 +392,9 @@ async def test_invalid_api_key_raises_401(self): from fastapi.security import HTTPAuthorizationCredentials mock_request = MagicMock() - credentials = HTTPAuthorizationCredentials( - scheme="Bearer", credentials="wrong-key" - ) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="wrong-key") - with patch.object( - src.auth.auth_manager, "get_api_key", return_value="correct-key" - ): + with patch.object(src.auth.auth_manager, "get_api_key", return_value="correct-key"): with pytest.raises(HTTPException) as exc_info: await src.auth.verify_api_key(mock_request, credentials) assert exc_info.value.status_code == 401 @@ -428,9 +411,7 @@ async def test_missing_credentials_raises_401(self): mock_request = MagicMock() # Mock security to return None (no credentials) with patch.object(src.auth, "security", AsyncMock(return_value=None)): - with patch.object( - src.auth.auth_manager, "get_api_key", return_value="test-key" - ): + with patch.object(src.auth.auth_manager, "get_api_key", return_value="test-key"): with pytest.raises(HTTPException) as exc_info: await src.auth.verify_api_key(mock_request, None) assert exc_info.value.status_code == 401 diff --git a/tests/test_claude_cli_unit.py b/tests/test_claude_cli_unit.py index 5b304f8..c67c7fe 100644 --- a/tests/test_claude_cli_unit.py +++ b/tests/test_claude_cli_unit.py @@ -21,6 +21,7 @@ class TestClaudeCodeCLIParseMessage: def cli_class(self): """Get the ClaudeCodeCLI class without instantiating.""" from src.claude_cli import ClaudeCodeCLI + return ClaudeCodeCLI def test_parse_result_message(self, cli_class): @@ -79,9 +80,7 @@ def test_parse_old_format_assistant_message(self, cli_class): messages = [ { "type": "assistant", - "message": { - "content": [{"type": "text", "text": "Old format response"}] - }, + "message": {"content": [{"type": "text", "text": "Old format response"}]}, } ] result = cli.parse_claude_message(messages) @@ -150,6 +149,7 @@ class TestClaudeCodeCLIExtractMetadata: def cli_class(self): """Get the ClaudeCodeCLI class.""" from src.claude_cli import ClaudeCodeCLI + return ClaudeCodeCLI def test_extract_from_result_message(self, cli_class): @@ -248,6 +248,7 @@ class TestClaudeCodeCLIEstimateTokenUsage: def cli_class(self): """Get the ClaudeCodeCLI class.""" from src.claude_cli import ClaudeCodeCLI + return ClaudeCodeCLI def test_estimate_basic(self, cli_class): @@ -371,6 +372,7 @@ def test_init_with_cwd(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + cli = ClaudeCodeCLI(cwd=temp_dir) assert cli.cwd == Path(temp_dir) @@ -385,6 +387,7 @@ def test_init_with_invalid_cwd_raises(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + with pytest.raises(ValueError, match="Working directory does not exist"): ClaudeCodeCLI(cwd="/nonexistent/path/12345") @@ -397,6 +400,7 @@ def test_init_without_cwd_creates_temp(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + cli = ClaudeCodeCLI() assert cli.temp_dir is not None @@ -406,6 +410,7 @@ def test_init_without_cwd_creates_temp(self): # Cleanup if cli.temp_dir and os.path.exists(cli.temp_dir): import shutil + shutil.rmtree(cli.temp_dir) def test_init_with_custom_timeout(self): @@ -417,6 +422,7 @@ def test_init_with_custom_timeout(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + cli = ClaudeCodeCLI(timeout=120000, cwd=temp_dir) assert cli.timeout == 120.0 @@ -431,6 +437,7 @@ def test_init_auth_validation_failure(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + # Should not raise, just log warning cli = ClaudeCodeCLI(cwd=temp_dir) assert cli.cwd == Path(temp_dir) @@ -449,6 +456,7 @@ def cli_instance(self): mock_auth.get_claude_code_env_vars.return_value = {} from src.claude_cli import ClaudeCodeCLI + cli = ClaudeCodeCLI(cwd=temp_dir) yield cli @@ -467,6 +475,7 @@ async def mock_query(*args, **kwargs): @pytest.mark.asyncio async def test_verify_cli_no_messages(self, cli_instance): """verify_cli returns False when no messages returned.""" + async def mock_query(*args, **kwargs): return yield # Make it a generator but yield nothing @@ -478,6 +487,7 @@ async def mock_query(*args, **kwargs): @pytest.mark.asyncio async def test_verify_cli_exception(self, cli_instance): """verify_cli returns False on exception.""" + async def mock_query(*args, **kwargs): raise RuntimeError("SDK error") yield # Make it a generator @@ -497,9 +507,12 @@ def cli_instance(self): with patch("src.auth.validate_claude_code_auth") as mock_validate: with patch("src.auth.auth_manager") as mock_auth: mock_validate.return_value = (True, {"method": "anthropic"}) - mock_auth.get_claude_code_env_vars.return_value = {"ANTHROPIC_API_KEY": "test-key"} + mock_auth.get_claude_code_env_vars.return_value = { + "ANTHROPIC_API_KEY": "test-key" + } from src.claude_cli import ClaudeCodeCLI + cli = ClaudeCodeCLI(cwd=temp_dir) yield cli @@ -530,9 +543,7 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion( - "Hello", system_prompt="You are helpful" - ): + async for _ in cli_instance.run_completion("Hello", system_prompt="You are helpful"): pass assert len(captured_options) == 1 @@ -587,9 +598,7 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion( - "Hello", permission_mode="acceptEdits" - ): + async for _ in cli_instance.run_completion("Hello", permission_mode="acceptEdits"): pass assert captured_options[0].permission_mode == "acceptEdits" @@ -605,9 +614,7 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion( - "Hello", continue_session=True - ): + async for _ in cli_instance.run_completion("Hello", continue_session=True): pass assert captured_options[0].continue_session is True @@ -623,9 +630,7 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion( - "Hello", session_id="sess-123" - ): + async for _ in cli_instance.run_completion("Hello", session_id="sess-123"): pass assert captured_options[0].resume == "sess-123" @@ -654,6 +659,7 @@ async def mock_query(*args, **kwargs): @pytest.mark.asyncio async def test_run_completion_exception_yields_error(self, cli_instance): """run_completion yields error message on exception.""" + async def mock_query(*args, **kwargs): raise RuntimeError("SDK failed") yield # Make it a generator @@ -686,7 +692,10 @@ async def mock_query(*args, **kwargs): # Env should be restored if original_key is None: - assert "ANTHROPIC_API_KEY" not in os.environ or os.environ.get("ANTHROPIC_API_KEY") == original_key + assert ( + "ANTHROPIC_API_KEY" not in os.environ + or os.environ.get("ANTHROPIC_API_KEY") == original_key + ) else: assert os.environ.get("ANTHROPIC_API_KEY") == original_key @@ -711,5 +720,6 @@ def test_cleanup_exception_is_caught(self): # Clean up manually import shutil + if os.path.exists(temp_dir): shutil.rmtree(temp_dir) diff --git a/tests/test_message_adapter_unit.py b/tests/test_message_adapter_unit.py index 9ccd8aa..90f3c52 100644 --- a/tests/test_message_adapter_unit.py +++ b/tests/test_message_adapter_unit.py @@ -258,9 +258,7 @@ class TestFormatClaudeResponse: def test_basic_formatting(self): """Basic response formatting.""" - result = MessageAdapter.format_claude_response( - content="Hello!", model="claude-3-opus" - ) + result = MessageAdapter.format_claude_response(content="Hello!", model="claude-3-opus") assert result["role"] == "assistant" assert result["content"] == "Hello!" @@ -277,7 +275,7 @@ def test_custom_finish_reason(self): def test_preserves_content_exactly(self): """Content is preserved exactly as provided.""" - content = "Multi\nline\ncontent with special chars: <>&\"" + content = 'Multi\nline\ncontent with special chars: <>&"' result = MessageAdapter.format_claude_response(content=content, model="claude") assert result["content"] == content diff --git a/tests/test_models_unit.py b/tests/test_models_unit.py index fe15d44..5e6387d 100644 --- a/tests/test_models_unit.py +++ b/tests/test_models_unit.py @@ -139,34 +139,24 @@ def test_temperature_range_validation(self): # Invalid - too high with pytest.raises(ValueError): - ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], temperature=3.0 - ) + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=3.0) # Invalid - too low with pytest.raises(ValueError): - ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], temperature=-1.0 - ) + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], temperature=-1.0) def test_top_p_range_validation(self): """top_p must be between 0 and 1.""" - request = ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], top_p=0.5 - ) + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.5) assert request.top_p == 0.5 with pytest.raises(ValueError): - ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], top_p=1.5 - ) + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=1.5) def test_n_must_be_1(self): """n > 1 raises validation error.""" with pytest.raises(ValueError) as exc_info: - ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], n=3 - ) + ChatCompletionRequest(messages=[Message(role="user", content="Hi")], n=3) assert "multiple choices" in str(exc_info.value).lower() def test_presence_penalty_range(self): @@ -235,9 +225,7 @@ def test_get_sampling_instructions_high_temperature(self): def test_get_sampling_instructions_low_top_p(self): """Low top_p produces focused instructions.""" - request = ChatCompletionRequest( - messages=[Message(role="user", content="Hi")], top_p=0.3 - ) + request = ChatCompletionRequest(messages=[Message(role="user", content="Hi")], top_p=0.3) instructions = request.get_sampling_instructions() assert instructions is not None assert "probable" in instructions.lower() or "mainstream" in instructions.lower() @@ -430,9 +418,7 @@ def test_tool_configuration_response(self): def test_tool_configuration_request(self): """Can create ToolConfigurationRequest.""" - request = ToolConfigurationRequest( - allowed_tools=["Read", "Write"], session_id="test" - ) + request = ToolConfigurationRequest(allowed_tools=["Read", "Write"], session_id="test") assert len(request.allowed_tools) == 2 def test_tool_validation_response(self): @@ -535,9 +521,7 @@ def test_anthropic_message(self): def test_anthropic_message_with_blocks(self): """Can create AnthropicMessage with content blocks.""" - msg = AnthropicMessage( - role="assistant", content=[AnthropicTextBlock(text="Hi there")] - ) + msg = AnthropicMessage(role="assistant", content=[AnthropicTextBlock(text="Hi there")]) assert len(msg.content) == 1 def test_anthropic_messages_request(self): diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py index afb1398..6ad2d95 100644 --- a/tests/test_sdk_migration.py +++ b/tests/test_sdk_migration.py @@ -16,11 +16,7 @@ class TestSystemPromptFormats: def test_text_system_prompt_format(self): """Test text-based system prompt format.""" options = ClaudeAgentOptions( - max_turns=1, - system_prompt={ - "type": "text", - "text": "You are a helpful assistant." - } + max_turns=1, system_prompt={"type": "text", "text": "You are a helpful assistant."} ) assert options.system_prompt is not None assert isinstance(options.system_prompt, dict) @@ -29,11 +25,7 @@ def test_text_system_prompt_format(self): def test_preset_system_prompt_format(self): """Test preset-based system prompt format.""" options = ClaudeAgentOptions( - max_turns=1, - system_prompt={ - "type": "preset", - "preset": "claude_code" - } + max_turns=1, system_prompt={"type": "preset", "preset": "claude_code"} ) assert options.system_prompt is not None assert isinstance(options.system_prompt, dict) @@ -51,18 +43,13 @@ def test_basic_options_creation(self): def test_options_with_model(self): """Test options with model specification.""" - options = ClaudeAgentOptions( - max_turns=1, - model="claude-sonnet-4-5-20250929" - ) + options = ClaudeAgentOptions(max_turns=1, model="claude-sonnet-4-5-20250929") assert options.model == "claude-sonnet-4-5-20250929" def test_options_with_tools(self): """Test options with tool restrictions.""" options = ClaudeAgentOptions( - max_turns=1, - allowed_tools=["Read", "Write"], - disallowed_tools=["Bash"] + max_turns=1, allowed_tools=["Read", "Write"], disallowed_tools=["Bash"] ) assert options.allowed_tools == ["Read", "Write"] assert options.disallowed_tools == ["Bash"] @@ -153,10 +140,7 @@ def test_chat_completion_request_creation(self): from src.models import ChatCompletionRequest request = ChatCompletionRequest( - model="claude-sonnet-4-5-20250929", - messages=[ - {"role": "user", "content": "Hello"} - ] + model="claude-sonnet-4-5-20250929", messages=[{"role": "user", "content": "Hello"}] ) assert request.model == "claude-sonnet-4-5-20250929" diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py index f74e076..961a385 100644 --- a/tests/test_session_manager_unit.py +++ b/tests/test_session_manager_unit.py @@ -101,9 +101,7 @@ def test_is_expired_false_for_new_session(self): def test_is_expired_true_for_past_expiry(self): """Session with past expiry is expired.""" - session = Session( - session_id="test-123", expires_at=datetime.utcnow() - timedelta(hours=1) - ) + session = Session(session_id="test-123", expires_at=datetime.utcnow() - timedelta(hours=1)) assert session.is_expired() is True def test_to_session_info_returns_correct_model(self): diff --git a/tests/test_textblock_fix.py b/tests/test_textblock_fix.py index c5b3409..69fc7db 100644 --- a/tests/test_textblock_fix.py +++ b/tests/test_textblock_fix.py @@ -8,92 +8,85 @@ import requests # Set debug mode -os.environ['DEBUG_MODE'] = 'true' +os.environ["DEBUG_MODE"] = "true" + def test_textblock_fix(): """Test that TextBlock content extraction is working.""" print("🧪 Testing TextBlock content extraction fix...") - + # Simple request that should trigger Claude to respond with normal text request_data = { "model": "claude-3-7-sonnet-20250219", - "messages": [ - { - "role": "user", - "content": "Hello! Can you briefly introduce yourself?" - } - ], + "messages": [{"role": "user", "content": "Hello! Can you briefly introduce yourself?"}], "stream": True, - "temperature": 0.0 + "temperature": 0.0, } - + try: # Send streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", - json=request_data, - stream=True, - timeout=30 + "http://localhost:8000/v1/chat/completions", json=request_data, stream=True, timeout=30 ) - + print(f"✅ Response status: {response.status_code}") - + if response.status_code != 200: print(f"❌ Request failed: {response.text}") return False - + # Parse streaming chunks and collect content all_content = "" has_role_chunk = False has_content = False - + for line in response.iter_lines(): if line: - line_str = line.decode('utf-8') - if line_str.startswith('data: '): + line_str = line.decode("utf-8") + if line_str.startswith("data: "): data_str = line_str[6:] # Remove "data: " prefix - + if data_str == "[DONE]": break - + try: chunk_data = json.loads(data_str) - + # Check chunk structure - if 'choices' in chunk_data and len(chunk_data['choices']) > 0: - choice = chunk_data['choices'][0] - delta = choice.get('delta', {}) - + if "choices" in chunk_data and len(chunk_data["choices"]) > 0: + choice = chunk_data["choices"][0] + delta = choice.get("delta", {}) + # Check for role chunk - if 'role' in delta: + if "role" in delta: has_role_chunk = True print(f"✅ Found role chunk") - - # Check for content chunk - if 'content' in delta: - content = delta['content'] + + # Check for content chunk + if "content" in delta: + content = delta["content"] all_content += content has_content = True print(f"✅ Found content: {content[:50]}...") - + except json.JSONDecodeError as e: print(f"❌ Invalid JSON in chunk: {data_str}") return False - + print(f"\n📊 Test Results:") print(f" Has role chunk: {has_role_chunk}") print(f" Has content: {has_content}") print(f" Total content length: {len(all_content)}") print(f" Content preview: {all_content[:200]}...") - + # Check if we got actual content instead of fallback message fallback_messages = [ "I'm unable to provide a response at the moment", - "I understand you're testing the system" + "I understand you're testing the system", ] - + is_fallback = any(msg in all_content for msg in fallback_messages) - + if has_content and not is_fallback and len(all_content) > 20: print("\n🎉 TextBlock fix is working!") print("✅ Real content extracted successfully") @@ -103,18 +96,19 @@ def test_textblock_fix(): print("\n❌ TextBlock fix is not working") print("⚠️ Still receiving fallback content or no content") return False - + except Exception as e: print(f"❌ Test failed with exception: {e}") return False + def main(): """Test the TextBlock fix.""" print("🔍 Testing TextBlock Content Extraction Fix") print("=" * 50) - + success = test_textblock_fix() - + print("\n" + "=" * 50) if success: print("🎉 TextBlock fix test PASSED!") @@ -122,9 +116,10 @@ def main(): else: print("❌ TextBlock fix test FAILED") print("⚠️ Issue may still persist") - + return success + if __name__ == "__main__": success = main() - exit(0 if success else 1) \ No newline at end of file + exit(0 if success else 1) diff --git a/tests/test_working_directory.py b/tests/test_working_directory.py index a275036..d552cba 100644 --- a/tests/test_working_directory.py +++ b/tests/test_working_directory.py @@ -15,33 +15,36 @@ from src.claude_cli import ClaudeCodeCLI + def test_default_temp_directory(): """Test that default working directory is a temp directory.""" print("Testing default temp directory creation...") - + # Ensure CLAUDE_CWD is not set original_cwd = os.environ.pop("CLAUDE_CWD", None) - + try: # Create CLI instance without cwd parameter cli = ClaudeCodeCLI() - + # Check that a temp directory was created assert cli.temp_dir is not None, "Temp directory should be created" - assert cli.temp_dir.startswith(tempfile.gettempdir()), f"Temp dir should be in system temp: {cli.temp_dir}" + assert cli.temp_dir.startswith( + tempfile.gettempdir() + ), f"Temp dir should be in system temp: {cli.temp_dir}" assert "claude_code_workspace_" in cli.temp_dir, "Temp dir should have correct prefix" assert os.path.exists(cli.cwd), f"Working directory should exist: {cli.cwd}" assert str(cli.cwd) == cli.temp_dir, "Working directory should be the temp directory" - + print(f" ✓ Created temp directory: {cli.temp_dir}") - + # Clean up manually for testing if cli.temp_dir and os.path.exists(cli.temp_dir): shutil.rmtree(cli.temp_dir) print(f" ✓ Cleaned up temp directory") - + return True - + except AssertionError as e: print(f" ✗ {e}") return False @@ -57,26 +60,26 @@ def test_default_temp_directory(): def test_env_var_directory(): """Test that CLAUDE_CWD environment variable is respected.""" print("\nTesting CLAUDE_CWD environment variable...") - + # Create a test directory test_dir = tempfile.mkdtemp(prefix="test_claude_cwd_") original_cwd = os.environ.get("CLAUDE_CWD") - + try: # Set CLAUDE_CWD environment variable os.environ["CLAUDE_CWD"] = test_dir - + # Create CLI instance - it reads from env var directly cli = ClaudeCodeCLI(cwd=os.environ.get("CLAUDE_CWD")) - + # Check that the specified directory is used assert cli.temp_dir is None, "No temp directory should be created when CLAUDE_CWD exists" assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}" - + print(f" ✓ Using CLAUDE_CWD: {test_dir}") - + return True - + except AssertionError as e: print(f" ✗ {e}") return False @@ -96,22 +99,22 @@ def test_env_var_directory(): def test_explicit_cwd_parameter(): """Test that explicit cwd parameter takes precedence.""" print("\nTesting explicit cwd parameter...") - + # Create a test directory test_dir = tempfile.mkdtemp(prefix="test_explicit_cwd_") - + try: # Create CLI instance with explicit cwd cli = ClaudeCodeCLI(cwd=test_dir) - + # Check that the specified directory is used assert cli.temp_dir is None, "No temp directory should be created when cwd is provided" assert str(cli.cwd) == test_dir, f"Working directory should be {test_dir}, got {cli.cwd}" - + print(f" ✓ Using explicit cwd: {test_dir}") - + return True - + except AssertionError as e: print(f" ✗ {e}") return False @@ -127,9 +130,9 @@ def test_explicit_cwd_parameter(): def test_nonexistent_directory_error(): """Test that specifying a non-existent directory raises an error.""" print("\nTesting non-existent directory handling...") - + non_existent_dir = "/this/directory/does/not/exist/12345" - + try: # Try to create CLI instance with non-existent directory cli = ClaudeCodeCLI(cwd=non_existent_dir) @@ -150,31 +153,31 @@ def test_nonexistent_directory_error(): def test_cross_platform_compatibility(): """Test that temp directory creation works across platforms.""" print("\nTesting cross-platform compatibility...") - + try: # Get platform-specific temp directory system_temp = tempfile.gettempdir() print(f" System temp directory: {system_temp}") - + # Create CLI instance cli = ClaudeCodeCLI() - + # Verify temp directory is in the correct location assert cli.temp_dir.startswith(system_temp), f"Temp dir should be in {system_temp}" - + # Verify path handling works correctly assert isinstance(cli.cwd, Path), "Working directory should be a Path object" assert cli.cwd.exists(), "Working directory should exist" - + print(f" ✓ Platform: {os.name}") print(f" ✓ Temp directory created correctly") - + # Clean up if cli.temp_dir and os.path.exists(cli.temp_dir): shutil.rmtree(cli.temp_dir) - + return True - + except Exception as e: print(f" ✗ Error: {e}") return False @@ -185,15 +188,15 @@ def main(): print("=" * 60) print("Testing Working Directory Configuration") print("=" * 60) - + tests = [ test_default_temp_directory, test_env_var_directory, test_explicit_cwd_parameter, test_nonexistent_directory_error, - test_cross_platform_compatibility + test_cross_platform_compatibility, ] - + results = [] for test in tests: try: @@ -201,11 +204,11 @@ def main(): except Exception as e: print(f"\n✗ Test {test.__name__} failed with exception: {e}") results.append(False) - + print("\n" + "=" * 60) passed = sum(results) total = len(results) - + if passed == total: print(f"✅ All {total} tests passed!") return 0 @@ -215,4 +218,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) From 91fa388c35d8e55d5d4bd1ed0f0b43bbef58c02b Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 09:56:56 -0500 Subject: [PATCH 22/27] ci: remove Claude Code Review workflow (token issues) Authored by: Aaron Lippold --- .github/workflows/claude-code-review.yml | 78 ------------------------ 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 5bf8ce5..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" - - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback. - - # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR - # use_sticky_comment: true - - # Optional: Customize review based on file types - # direct_prompt: | - # Review this PR focusing on: - # - For TypeScript files: Type safety and proper interface usage - # - For API endpoints: Security, input validation, and error handling - # - For React components: Performance, accessibility, and best practices - # - For tests: Coverage, edge cases, and test quality - - # Optional: Different prompts for different authors - # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && - # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || - # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - - # Optional: Add specific tools for running tests or linting - # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') - From be53393f3245cf36664ae48236fcbaf7c0b22157 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 10:04:45 -0500 Subject: [PATCH 23/27] Revert: restore claude-code-review.yml Authored by: Aaron Lippold --- .github/workflows/claude-code-review.yml | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..5bf8ce5 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,78 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + From d87d6c42c4f9ed7e0566127c526747e1d41ef6e2 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 10:46:20 -0500 Subject: [PATCH 24/27] feat: implement Claude Code review recommendations Security enhancements: - Add RequestSizeLimitMiddleware (10MB default, configurable) - Add RequestIDMiddleware for audit trail (X-Request-ID header) - Add request ID correlation in debug logs CI/CD improvements: - Add mypy type checking (non-blocking) - Add bandit security scanning - Add safety dependency vulnerability check - Add hypothesis for property-based testing Code fixes: - Add 'plan' to VALID_PERMISSION_MODES (was missing) Dependencies: - mypy ^1.14.0 - bandit ^1.8.0 - safety ^3.2.0 - hypothesis ^6.122.0 Authored by: Aaron Lippold --- .github/workflows/ci.yml | 11 + poetry.lock | 910 ++++++++++++++++++++++++++++++++++- pyproject.toml | 4 + src/main.py | 51 +- src/parameter_validator.py | 2 +- tests/test_property_based.py | 100 ++++ 6 files changed, 1068 insertions(+), 10 deletions(-) create mode 100644 tests/test_property_based.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24c6ed7..7df474a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,17 @@ jobs: - name: Run linting run: poetry run black --check src tests + - name: Type checking + run: poetry run mypy src --ignore-missing-imports + continue-on-error: true + + - name: Security scan + run: poetry run bandit -r src/ -ll -x tests + + - name: Dependency vulnerability scan + run: poetry run safety check || true + continue-on-error: true + - name: Run tests run: poetry run pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing diff --git a/poetry.lock b/poetry.lock index 2dcefb7..03d8e92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -47,6 +47,104 @@ files = [ {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] +[[package]] +name = "authlib" +version = "1.6.6" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, + {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, +] + +[package.dependencies] +cryptography = "*" + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, +] + +[[package]] +name = "bandit" +version = "1.9.2" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868"}, + {file = "bandit-1.9.2.tar.gz", hash = "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] +yaml = ["PyYAML"] + [[package]] name = "black" version = "24.10.0" @@ -112,7 +210,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, @@ -471,7 +569,7 @@ version = "46.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, @@ -573,6 +671,28 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dparse" +version = "0.6.4" +description = "A parser for Python dependency files" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, + {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, +] + +[package.dependencies] +packaging = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +all = ["pipenv", "poetry", "pyyaml"] +conda = ["pyyaml"] +pipenv = ["pipenv"] +poetry = ["poetry"] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -613,6 +733,18 @@ typing-extensions = ">=4.8.0" all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "filelock" +version = "3.20.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"}, + {file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -741,6 +873,40 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "hypothesis" +version = "6.148.8" +description = "The property-based testing library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "hypothesis-6.148.8-py3-none-any.whl", hash = "sha256:c1842f47f974d74661b3779a26032f8b91bc1eb30d84741714d3712d7f43e85e"}, + {file = "hypothesis-6.148.8.tar.gz", hash = "sha256:fa6b2ae029bc02f9d2d6c2257b0cbf2dc3782362457d2027a038ad7f4209c385"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.101)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.101)", "hypothesis-crosshair (>=0.0.27)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=20.8b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.21.6)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + [[package]] name = "idna" version = "3.10" @@ -768,6 +934,24 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "jiter" version = "0.10.0" @@ -855,6 +1039,18 @@ files = [ {file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"}, ] +[[package]] +name = "joblib" +version = "1.5.3" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, + {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -892,6 +1088,93 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "librt" +version = "0.7.5" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.7.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81056e01bba1394f1d92904ec61a4078f66df785316275edbaf51d90da8c6e26"}, + {file = "librt-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d7c72c8756eeb3aefb1b9e3dac7c37a4a25db63640cac0ab6fc18e91a0edf05a"}, + {file = "librt-0.7.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ddc4a16207f88f9597b397fc1f60781266d13b13de922ff61c206547a29e4bbd"}, + {file = "librt-0.7.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63055d3dda433ebb314c9f1819942f16a19203c454508fdb2d167613f7017169"}, + {file = "librt-0.7.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f85f9b5db87b0f52e53c68ad2a0c5a53e00afa439bd54a1723742a2b1021276"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c566a4672564c5d54d8ab65cdaae5a87ee14c1564c1a2ddc7a9f5811c750f023"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fee15c2a190ef389f14928135c6fb2d25cd3fdb7887bfd9a7b444bbdc8c06b96"}, + {file = "librt-0.7.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:584cb3e605ec45ba350962cec853e17be0a25a772f21f09f1e422f7044ae2a7d"}, + {file = "librt-0.7.5-cp310-cp310-win32.whl", hash = "sha256:9c08527055fbb03c641c15bbc5b79dd2942fb6a3bd8dabf141dd7e97eeea4904"}, + {file = "librt-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:dd810f2d39c526c42ea205e0addad5dc08ef853c625387806a29d07f9d150d9b"}, + {file = "librt-0.7.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f952e1a78c480edee8fb43aa2bf2e84dcd46c917d44f8065b883079d3893e8fc"}, + {file = "librt-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75965c1f4efb7234ff52a58b729d245a21e87e4b6a26a0ec08052f02b16274e4"}, + {file = "librt-0.7.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:732e0aa0385b59a1b2545159e781c792cc58ce9c134249233a7c7250a44684c4"}, + {file = "librt-0.7.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdde31759bd8888f3ef0eebda80394a48961328a17c264dce8cc35f4b9cde35d"}, + {file = "librt-0.7.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3146d52465b3b6397d25d513f428cb421c18df65b7378667bb5f1e3cc45805"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29c8d2fae11d4379ea207ba7fc69d43237e42cf8a9f90ec6e05993687e6d648b"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb41f04046b4f22b1e7ba5ef513402cd2e3477ec610e5f92d38fe2bba383d419"}, + {file = "librt-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bb7883c1e94ceb87c2bf81385266f032da09cd040e804cc002f2c9d6b842e2f"}, + {file = "librt-0.7.5-cp311-cp311-win32.whl", hash = "sha256:84d4a6b9efd6124f728558a18e79e7cc5c5d4efc09b2b846c910de7e564f5bad"}, + {file = "librt-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab4b0d3bee6f6ff7017e18e576ac7e41a06697d8dea4b8f3ab9e0c8e1300c409"}, + {file = "librt-0.7.5-cp311-cp311-win_arm64.whl", hash = "sha256:730be847daad773a3c898943cf67fb9845a3961d06fb79672ceb0a8cd8624cfa"}, + {file = "librt-0.7.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba1077c562a046208a2dc6366227b3eeae8f2c2ab4b41eaf4fd2fa28cece4203"}, + {file = "librt-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:654fdc971c76348a73af5240d8e2529265b9a7ba6321e38dd5bae7b0d4ab3abe"}, + {file = "librt-0.7.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6b7b58913d475911f6f33e8082f19dd9b120c4f4a5c911d07e395d67b81c6982"}, + {file = "librt-0.7.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e0fd344bad57026a8f4ccfaf406486c2fc991838050c2fef156170edc3b775"}, + {file = "librt-0.7.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46aa91813c267c3f60db75d56419b42c0c0b9748ec2c568a0e3588e543fb4233"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ddc0ab9dbc5f9ceaf2bf7a367bf01f2697660e908f6534800e88f43590b271db"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a488908a470451338607650f1c064175094aedebf4a4fa37890682e30ce0b57"}, + {file = "librt-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47fc52602ffc374e69bf1b76536dc99f7f6dd876bd786c8213eaa3598be030a"}, + {file = "librt-0.7.5-cp312-cp312-win32.whl", hash = "sha256:cda8b025875946ffff5a9a7590bf9acde3eb02cb6200f06a2d3e691ef3d9955b"}, + {file = "librt-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:b591c094afd0ffda820e931148c9e48dc31a556dc5b2b9b3cc552fa710d858e4"}, + {file = "librt-0.7.5-cp312-cp312-win_arm64.whl", hash = "sha256:532ddc6a8a6ca341b1cd7f4d999043e4c71a212b26fe9fd2e7f1e8bb4e873544"}, + {file = "librt-0.7.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a"}, + {file = "librt-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0"}, + {file = "librt-0.7.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5"}, + {file = "librt-0.7.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325"}, + {file = "librt-0.7.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25"}, + {file = "librt-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b"}, + {file = "librt-0.7.5-cp313-cp313-win32.whl", hash = "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee"}, + {file = "librt-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e"}, + {file = "librt-0.7.5-cp313-cp313-win_arm64.whl", hash = "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45"}, + {file = "librt-0.7.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2"}, + {file = "librt-0.7.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f"}, + {file = "librt-0.7.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6"}, + {file = "librt-0.7.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361"}, + {file = "librt-0.7.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760"}, + {file = "librt-0.7.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2"}, + {file = "librt-0.7.5-cp314-cp314-win32.whl", hash = "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8"}, + {file = "librt-0.7.5-cp314-cp314-win_amd64.whl", hash = "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e"}, + {file = "librt-0.7.5-cp314-cp314-win_arm64.whl", hash = "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d"}, + {file = "librt-0.7.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802"}, + {file = "librt-0.7.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5"}, + {file = "librt-0.7.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7"}, + {file = "librt-0.7.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf"}, + {file = "librt-0.7.5-cp314-cp314t-win32.whl", hash = "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d"}, + {file = "librt-0.7.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d"}, + {file = "librt-0.7.5-cp314-cp314t-win_arm64.whl", hash = "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1"}, + {file = "librt-0.7.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:df2e210400b28e50994477ebf82f055698c79797b6ee47a1669d383ca33263e1"}, + {file = "librt-0.7.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d2cc7d187e8c6e9b7bdbefa9697ce897a704ea7a7ce844f2b4e0e2aa07ae51d3"}, + {file = "librt-0.7.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39183abee670bc37b85f11e86c44a9cad1ed6efa48b580083e89ecee13dd9717"}, + {file = "librt-0.7.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191cbd42660446d67cf7a95ac7bfa60f49b8b3b0417c64f216284a1d86fc9335"}, + {file = "librt-0.7.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea1b60b86595a5dc1f57b44a801a1c4d8209c0a69518391d349973a4491408e6"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:af69d9e159575e877c7546d1ee817b4ae089aa221dd1117e20c24ad8dc8659c7"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0e2bf8f91093fac43e3eaebacf777f12fd539dce9ec5af3efc6d8424e96ccd49"}, + {file = "librt-0.7.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8dcae24de1bc9da93aa689cb6313c70e776d7cea2fcf26b9b6160fedfe6bd9af"}, + {file = "librt-0.7.5-cp39-cp39-win32.whl", hash = "sha256:cdb001a1a0e4f41e613bca2c0fc147fc8a7396f53fc94201cbfd8ec7cd69ca4b"}, + {file = "librt-0.7.5-cp39-cp39-win_amd64.whl", hash = "sha256:a9eacbf983319b26b5f340a2e0cd47ac1ee4725a7f3a72fd0f15063c934b69d6"}, + {file = "librt-0.7.5.tar.gz", hash = "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa"}, +] + [[package]] name = "limits" version = "5.4.0" @@ -921,6 +1204,150 @@ redis = ["redis (>3,!=4.5.2,!=4.5.3,<6.0.0)"] rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"] valkey = ["valkey (>=6)"] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "marshmallow" +version = "4.1.2" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "marshmallow-4.1.2-py3-none-any.whl", hash = "sha256:a8cfa18bd8d0e5f7339e734edf84815fe8db1bdb57358c7ccc05472b746eeadc"}, + {file = "marshmallow-4.1.2.tar.gz", hash = "sha256:083f250643d2e75fd363f256aeb6b1af369a7513ad37647ce4a601f6966e3ba5"}, +] + +[package.dependencies] +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2025.9.25)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.13.0)"] +tests = ["pytest", "simplejson"] + [[package]] name = "mcp" version = "1.20.0" @@ -952,6 +1379,80 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "1.19.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, +] + +[package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -964,6 +1465,32 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "nltk" +version = "3.9.2" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, + {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + [[package]] name = "openai" version = "1.93.0" @@ -1055,7 +1582,7 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, @@ -1383,7 +1910,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1457,6 +1984,131 @@ attrs = ">=22.2.0" rpds-py = ">=0.7.0" typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} +[[package]] +name = "regex" +version = "2025.11.3" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"}, + {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"}, + {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"}, + {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"}, + {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"}, + {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"}, + {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"}, + {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"}, + {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"}, + {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"}, + {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"}, + {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"}, + {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"}, + {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"}, + {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"}, + {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"}, + {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"}, + {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"}, + {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"}, + {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"}, + {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"}, + {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"}, + {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"}, + {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"}, + {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"}, + {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"}, +] + [[package]] name = "requests" version = "2.32.4" @@ -1479,6 +2131,25 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rich" +version = "14.2.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["dev"] +files = [ + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rpds-py" version = "0.28.0" @@ -1604,6 +2275,165 @@ files = [ {file = "rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea"}, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d"}, + {file = "ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.15", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation == \"CPython\" and python_version < \"3.15\"" +files = [ + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f"}, + {file = "ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c"}, + {file = "ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144"}, + {file = "ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec"}, + {file = "ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa"}, + {file = "ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:923816815974425fbb1f1bf57e85eca6e14d8adc313c66db21c094927ad01815"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dcc7f3162d3711fd5d52e2267e44636e3e566d1e5675a5f0b30e98f2c4af7974"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d3c9210219cbc0f22706f19b154c9a798ff65a6beeafbf77fc9c057ec806f7d"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bb7b728fd9f405aa00b4a0b17ba3f3b810d0ccc5f77f7373162e9b5f0ff75d5"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cb75a3c14f1d6c3c2a94631e362802f70e83e20d1f2b2ef3026c05b415c4900"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:badd1d7283f3e5894779a6ea8944cc765138b96804496c91812b2829f70e18a7"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0ba6604bbc3dfcef844631932d06a1a4dcac3fee904efccf582261948431628a"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8220fd4c6f98485e97aea65e1df76d4fed1678ede1fe1d0eed2957230d287c4"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win32.whl", hash = "sha256:04d21dc9c57d9608225da28285900762befbb0165ae48482c15d8d4989d4af14"}, + {file = "ruamel_yaml_clib-0.2.15-cp39-cp39-win_amd64.whl", hash = "sha256:27dc656e84396e6d687f97c6e65fb284d100483628f02d95464fd731743a4afe"}, + {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"}, +] + +[[package]] +name = "safety" +version = "3.7.0" +description = "Scan dependencies for known vulnerabilities and licenses." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "safety-3.7.0-py3-none-any.whl", hash = "sha256:65e71db45eb832e8840e3456333d44c23927423753d5610596a09e909a66d2bf"}, + {file = "safety-3.7.0.tar.gz", hash = "sha256:daec15a393cafc32b846b7ef93f9c952a1708863e242341ab5bde2e4beabb54e"}, +] + +[package.dependencies] +authlib = ">=1.2.0" +click = ">=8.0.2" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<4.0" +httpx = "*" +jinja2 = ">=3.1.0" +marshmallow = ">=3.15.0" +nltk = ">=3.9" +packaging = ">=21.0" +pydantic = ">=2.6.0" +requests = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.16" +tenacity = ">=8.1.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} +tomlkit = "*" +typer = ">=0.16.0" +typing-extensions = ">=4.7.1" + +[package.extras] +github = ["pygithub (>=1.43.3)"] +gitlab = ["python-gitlab (>=1.3.0)"] +spdx = ["spdx-tools (>=0.8.2)"] + +[[package]] +name = "safety-schemas" +version = "0.0.16" +description = "Schemas for Safety tools" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "safety_schemas-0.0.16-py3-none-any.whl", hash = "sha256:6760515d3fd1e6535b251cd73014bd431d12fe0bfb8b6e8880a9379b5ab7aa44"}, + {file = "safety_schemas-0.0.16.tar.gz", hash = "sha256:3bb04d11bd4b5cc79f9fa183c658a6a8cf827a9ceec443a5ffa6eed38a50a24e"}, +] + +[package.dependencies] +dparse = ">=0.6.4" +packaging = ">=21.0" +pydantic = ">=2.6.0" +ruamel-yaml = ">=0.17.21" +typing-extensions = ">=4.7.1" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "slowapi" version = "0.1.9" @@ -1634,6 +2464,18 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "sse-starlette" version = "2.3.6" @@ -1673,6 +2515,34 @@ anyio = ">=3.6.2,<5" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "stevedore" +version = "5.6.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, + {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tomli" version = "2.2.1" @@ -1716,6 +2586,18 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1738,6 +2620,24 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "typer" +version = "0.21.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6"}, + {file = "typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + [[package]] name = "typing-extensions" version = "4.14.0" @@ -2153,4 +3053,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "7be3c6de9a9151c0301eb5ed608754a17eeef7db225fc997c0aeb2c29d80f3ea" +content-hash = "995cbb6b6bfbf14612eff7e0690ca47fc7b0c01fd2ef3351dea01d6940be0ed6" diff --git a/pyproject.toml b/pyproject.toml index c69616e..ff00fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,10 @@ pytest-asyncio = "^0.23.0" requests = "^2.32.0" openai = "^1.0.0" pytest-cov = "^7.0.0" +mypy = "^1.14.0" +bandit = "^1.8.0" +safety = "^3.2.0" +hypothesis = "^6.122.0" [build-system] requires = ["poetry-core"] diff --git a/src/main.py b/src/main.py index e9a1981..b84ed8b 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,7 @@ import logging import secrets import string +import uuid from typing import Optional, AsyncGenerator, Dict, Any from contextlib import asynccontextmanager @@ -220,23 +221,65 @@ async def lifespan(app: FastAPI): app.state.limiter = limiter app.add_exception_handler(429, rate_limit_exceeded_handler) -# Add debug logging middleware +# Security configuration +MAX_REQUEST_SIZE = int(os.getenv("MAX_REQUEST_SIZE", str(10 * 1024 * 1024))) # 10MB default + +# Add middleware from starlette.middleware.base import BaseHTTPMiddleware +class RequestIDMiddleware(BaseHTTPMiddleware): + """Add unique request ID to each request for audit trails.""" + + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request.state.request_id = request_id + + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +class RequestSizeLimitMiddleware(BaseHTTPMiddleware): + """Limit request body size to prevent DoS attacks.""" + + async def dispatch(self, request: Request, call_next): + content_length = request.headers.get("content-length") + if content_length and int(content_length) > MAX_REQUEST_SIZE: + return JSONResponse( + status_code=413, + content={ + "error": { + "message": f"Request body too large. Maximum size is {MAX_REQUEST_SIZE} bytes.", + "type": "request_too_large", + "code": 413, + } + }, + ) + return await call_next(request) + + +# Add security middleware (order matters - first added = last executed) +app.add_middleware(RequestIDMiddleware) +app.add_middleware(RequestSizeLimitMiddleware) + + class DebugLoggingMiddleware(BaseHTTPMiddleware): """ASGI-compliant middleware for logging request/response details when debug mode is enabled.""" async def dispatch(self, request: Request, call_next): + # Get request ID for correlation + request_id = getattr(request.state, "request_id", "unknown") + if not (DEBUG_MODE or VERBOSE): return await call_next(request) # Log request details start_time = asyncio.get_event_loop().time() - # Log basic request info - logger.debug(f"🔍 Incoming request: {request.method} {request.url}") - logger.debug(f"🔍 Headers: {dict(request.headers)}") + # Log basic request info with request ID for correlation + logger.debug(f"🔍 [{request_id}] Incoming request: {request.method} {request.url}") + logger.debug(f"🔍 [{request_id}] Headers: {dict(request.headers)}") # For POST requests, try to log body (but don't break if we can't) body_logged = False diff --git a/src/parameter_validator.py b/src/parameter_validator.py index 06bbb43..e45452f 100644 --- a/src/parameter_validator.py +++ b/src/parameter_validator.py @@ -17,7 +17,7 @@ class ParameterValidator: SUPPORTED_MODELS = set(CLAUDE_MODELS) # Valid permission modes for Claude Code SDK - VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions"} + VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan"} @classmethod def validate_model(cls, model: str) -> bool: diff --git a/tests/test_property_based.py b/tests/test_property_based.py new file mode 100644 index 0000000..f0b3edf --- /dev/null +++ b/tests/test_property_based.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Property-based tests using Hypothesis for edge case discovery. + +These tests generate random inputs to find edge cases that manual testing might miss. +""" + +import pytest +from hypothesis import given, strategies as st, settings, assume + +from src.message_adapter import MessageAdapter +from src.parameter_validator import ParameterValidator +from src.constants import CLAUDE_MODELS + + +class TestMessageAdapterProperties: + """Property-based tests for MessageAdapter.""" + + @given(content=st.text(min_size=1, max_size=1000)) + @settings(max_examples=50) + def test_filter_content_handles_any_text(self, content: str): + """filter_content should handle any text without crashing.""" + assume("\x00" not in content) # Null bytes + + # Should not crash + result = MessageAdapter.filter_content(content) + assert isinstance(result, str) + + @given(text=st.text(min_size=0, max_size=10000)) + @settings(max_examples=30) + def test_estimate_tokens_returns_positive(self, text: str): + """Token estimation should return a non-negative integer.""" + result = MessageAdapter.estimate_tokens(text) + assert isinstance(result, int) + assert result >= 0 + + +class TestParameterValidatorProperties: + """Property-based tests for ParameterValidator.""" + + @given(model=st.sampled_from(CLAUDE_MODELS)) + @settings(max_examples=20) + def test_valid_model_names_accepted(self, model: str): + """Valid Claude model names should be accepted.""" + result = ParameterValidator.validate_model(model) + # validate_model always returns True (allows graceful degradation) + assert result is True + + @given(model=st.text(min_size=1, max_size=50)) + @settings(max_examples=30) + def test_any_model_name_accepted_gracefully(self, model: str): + """Any model name is accepted (graceful degradation).""" + assume(model.strip()) # Non-empty + + # validate_model always returns True to allow trying unknown models + result = ParameterValidator.validate_model(model) + assert result is True + + @given(permission_mode=st.sampled_from(["default", "acceptEdits", "bypassPermissions", "plan"])) + @settings(max_examples=10) + def test_valid_permission_modes_accepted(self, permission_mode: str): + """Valid permission modes should be accepted.""" + result = ParameterValidator.validate_permission_mode(permission_mode) + assert result is True + + @given(permission_mode=st.text(min_size=1, max_size=30)) + @settings(max_examples=20) + def test_invalid_permission_modes_rejected(self, permission_mode: str): + """Invalid permission modes should be rejected.""" + valid_modes = {"default", "acceptEdits", "bypassPermissions", "plan"} + assume(permission_mode not in valid_modes) + + result = ParameterValidator.validate_permission_mode(permission_mode) + assert result is False + + +class TestTokenEstimation: + """Property-based tests for token estimation consistency.""" + + @given(text=st.text(min_size=0, max_size=5000)) + @settings(max_examples=30) + def test_token_estimation_non_negative(self, text: str): + """Token estimation should always return non-negative value.""" + tokens = MessageAdapter.estimate_tokens(text) + assert tokens >= 0 + + @given( + prefix=st.text(min_size=10, max_size=100), + suffix=st.text(min_size=10, max_size=100), + ) + @settings(max_examples=20) + def test_concatenation_increases_tokens(self, prefix: str, suffix: str): + """Concatenating text should not decrease token count.""" + tokens_prefix = MessageAdapter.estimate_tokens(prefix) + tokens_suffix = MessageAdapter.estimate_tokens(suffix) + tokens_combined = MessageAdapter.estimate_tokens(prefix + suffix) + + # Combined should be at least as many as the larger part + # (may be less than sum due to subword tokenization) + assert tokens_combined >= min(tokens_prefix, tokens_suffix) From b20904a674b8a5d645f52a4554706e335d0a0cd6 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 10:53:00 -0500 Subject: [PATCH 25/27] fix: add nosec comments for intentional 0.0.0.0 binding - Make host configurable via CLAUDE_WRAPPER_HOST env var - Add nosec B104 comments to suppress bandit warnings - Binding to 0.0.0.0 is intentional for container/development use Authored by: Aaron Lippold --- src/main.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index b84ed8b..4a74aa4 100644 --- a/src/main.py +++ b/src/main.py @@ -1939,7 +1939,7 @@ def find_available_port(start_port: int = 8000, max_attempts: int = 10) -> int: ) -def run_server(port: int = None): +def run_server(port: int = None, host: str = None): """Run the server - used as Poetry script entry point.""" import uvicorn @@ -1950,11 +1950,15 @@ def run_server(port: int = None): # Priority: CLI arg > ENV var > default if port is None: port = int(os.getenv("PORT", "8000")) + if host is None: + # Default to 0.0.0.0 for container/development use (configurable via CLAUDE_WRAPPER_HOST env) + host = os.getenv("CLAUDE_WRAPPER_HOST", "0.0.0.0") # nosec B104 preferred_port = port try: # Try the preferred port first - uvicorn.run(app, host="0.0.0.0", port=preferred_port) + # Binding to 0.0.0.0 is intentional for container/development use + uvicorn.run(app, host=host, port=preferred_port) # nosec B104 except OSError as e: if "Address already in use" in str(e) or e.errno == 48: logger.warning(f"Port {preferred_port} is already in use. Finding alternative port...") @@ -1963,7 +1967,8 @@ def run_server(port: int = None): logger.info(f"Starting server on alternative port {available_port}") print(f"\n🚀 Server starting on http://localhost:{available_port}") print(f"📝 Update your client base_url to: http://localhost:{available_port}/v1") - uvicorn.run(app, host="0.0.0.0", port=available_port) + # Binding to 0.0.0.0 is intentional for container/development use + uvicorn.run(app, host=host, port=available_port) # nosec B104 except RuntimeError as port_error: logger.error(f"Could not find available port: {port_error}") print(f"\n❌ Error: {port_error}") From 7b8f7701dcdad2db8e946443dfd9c9c359d1b132 Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 10:54:07 -0500 Subject: [PATCH 26/27] docs: document CLAUDE_WRAPPER_HOST and MAX_REQUEST_SIZE env vars - Add CLAUDE_WRAPPER_HOST to .env.example and README - Add MAX_REQUEST_SIZE to .env.example and README - Document security settings for host binding Authored by: Aaron Lippold --- .env.example | 6 ++++++ README.md | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 6f6ed72..749c598 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,13 @@ CLAUDE_CLI_PATH=claude # If API_KEY is not set, server will prompt for interactive API key protection on startup # Leave commented out to enable interactive prompt, or uncomment to use a fixed API key # API_KEY=your-optional-api-key-here + +# Server Configuration PORT=8000 +# Host binding address - use 127.0.0.1 for local-only access, 0.0.0.0 for all interfaces +# CLAUDE_WRAPPER_HOST=0.0.0.0 +# Maximum request body size in bytes (default: 10MB) +# MAX_REQUEST_SIZE=10485760 # Timeout Configuration (milliseconds) MAX_TIMEOUT=600000 diff --git a/README.md b/README.md index 8fbd42b..72ee5c4 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,9 @@ Env vars override defaults and can be set at runtime with `-e` flags or in `dock - **Core Server Settings**: - `PORT=9000`: Changes the internal listening port (default: 8000; update port mapping accordingly). + - `CLAUDE_WRAPPER_HOST=127.0.0.1`: Sets the host binding address (default: 0.0.0.0 for all interfaces; use 127.0.0.1 for local-only access). - `MAX_TIMEOUT=600`: Sets the request timeout in seconds (default: 300; increase for complex Claude queries). + - `MAX_REQUEST_SIZE=10485760`: Maximum request body size in bytes (default: 10MB; increase for large payloads). - `CLAUDE_CWD=/path/to/workspace`: Sets Claude Code's working directory (default: isolated temp directory for security). - **Authentication and Providers**: From 01fd35ff78ec210a0a3de8e58c58c7e0a09cbf7d Mon Sep 17 00:00:00 2001 From: Aaron Lippold Date: Mon, 29 Dec 2025 11:01:36 -0500 Subject: [PATCH 27/27] docs: add usage examples to constants.py docstring Addresses Claude Code review recommendation for inline documentation. Authored by: Aaron Lippold --- src/constants.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/constants.py b/src/constants.py index 1bf914f..5fb452b 100644 --- a/src/constants.py +++ b/src/constants.py @@ -2,12 +2,33 @@ Constants and configuration for Claude Code OpenAI Wrapper. Single source of truth for tool names, models, and other configuration values. + +Usage Examples: + # Check if a model is supported + from src.constants import CLAUDE_MODELS + if model_name in CLAUDE_MODELS: + # proceed with request + + # Get default allowed tools + from src.constants import DEFAULT_ALLOWED_TOOLS + options = {"allowed_tools": DEFAULT_ALLOWED_TOOLS} + + # Use rate limits in FastAPI + from src.constants import RATE_LIMIT_CHAT + @limiter.limit(f"{RATE_LIMIT_CHAT}/minute") + async def chat_endpoint(): ... + +Note: + - Tool configurations are managed by ToolManager (see tool_manager.py) + - Model validation uses graceful degradation (warns but allows unknown models) + - Rate limits can be overridden via environment variables """ import os # Claude Agent SDK Tool Names # These are the built-in tools available in the Claude Agent SDK +# See: https://docs.anthropic.com/en/docs/claude-code/sdk CLAUDE_TOOLS = [ "Task", # Launch agents for complex tasks "Bash", # Execute bash commands