diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ab657273..413f1840 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.12", "3.13"] fail-fast: false defaults: run: diff --git a/.gitignore b/.gitignore index 2e7bc43e..0a642882 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ cython_debug/ # Reference directory - ignore all reference projects reference/ + +# Others +.pdm-python diff --git a/README.md b/README.md index 5b31ffa1..60b7de03 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ BUB_API_KEY=your-api-key-here BUB_MODEL=gpt-4 # AI model to use BUB_API_BASE=https://api.custom.ai # Custom API endpoint BUB_MAX_TOKENS=4000 # Maximum response tokens +BUB_TIMEOUT_SECONDS=30 # Timeout for AI responses (seconds) +BUB_MAX_ITERATIONS=10 # Maximum tool execution cycles BUB_WORKSPACE_PATH=/path/to/work # Default workspace BUB_SYSTEM_PROMPT="custom prompt" # Custom system prompt ``` diff --git a/docs/index.md b/docs/index.md index d21f1705..83dd7553 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,16 +36,6 @@ export BUB_API_KEY="sk-..." export BUB_PROVIDER="anthropic" export BUB_MODEL_NAME="claude-3-5-sonnet-20241022" export BUB_API_KEY="your-anthropic-key" - -# For local models with Ollama -export BUB_PROVIDER="ollama" -export BUB_MODEL_NAME="llama3" -# No API key needed for local models - -# For Groq (fast inference) -export BUB_PROVIDER="groq" -export BUB_MODEL_NAME="llama3-8b-8192" -export BUB_API_KEY="gsk_..." ``` ### Usage @@ -101,6 +91,8 @@ Configure Bub via environment variables or a `.env` file: | `BUB_MAX_TOKENS` | Maximum response tokens (optional) | `4000` | | `BUB_WORKSPACE_PATH` | Default workspace directory (optional) | `/path/to/work` | | `BUB_SYSTEM_PROMPT` | Custom system prompt (optional) | `"You are a helpful assistant..."` | +| `BUB_TIMEOUT_SECONDS` | Timeout for AI responses (seconds) | `30` | +| `BUB_MAX_ITERATIONS` | Maximum number of tool execution cycles | `10` | ### Custom System Prompt with BUB.md diff --git a/docs/internals/event-system-architecture.md b/docs/internals/event-system-architecture.md new file mode 100644 index 00000000..f0b3b687 --- /dev/null +++ b/docs/internals/event-system-architecture.md @@ -0,0 +1,420 @@ +# Event System Architecture + +A clean, adapter-based event system that supports both string and Enum event types, with native eventure integration for powerful event visualization and analysis. + +## 1. Quick Start + +```python +from bub.events import BaseEvent, DomainEventType, register_event, publish, subscribe + +# Define event types +class UserEventType(DomainEventType): + CREATED = "user.created" + UPDATED = "user.updated" + +# Create events +@register_event +class UserCreatedEvent(BaseEvent): + event_type = UserEventType.CREATED + user_id: str + username: str + +# Publish and subscribe +@subscribe(UserEventType.CREATED) +def handle_user_created(event): + print(f"User created: {event.data['username']}") + +publish(UserCreatedEvent(user_id="123", username="john")) +``` + +## 2. Architecture + +The event system is built with a clean, modular architecture: + +``` +src/bub/events/ +├── __init__.py # Main public API +├── types.py # Type definitions (EventType, DomainEventType) +├── models.py # Event model definitions (BaseEvent) +├── registry.py # Schema registry and management +├── adapters.py # Adapter interfaces +├── system.py # Main EventSystem facade +├── exceptions.py # Exception hierarchy +└── backends/ + ├── __init__.py # Backend exports + └── eventure.py # Eventure backend implementation +``` + +## 3. Core Features + +- **Flexible Event Types**: Support for both `str` and `Enum` event types +- **Type Safety**: Full type hints with `EventType = str | Enum` +- **Clean Architecture**: Adapter pattern for pluggable backends +- **Global Singleton**: Convenient global event system +- **Schema Registry**: Automatic event schema registration +- **Native Eventure**: Built-in visualization and cascade analysis +- **Multi-Bus Support**: Cross-bus event communication + +## 4. Design Patterns + +### 4.1 Event Type Flexibility + +The system supports both string and Enum event types seamlessly: + +```python +from bub.events.types import DomainEventType, EventType +from enum import Enum + +# String-based event types +class StringEvents: + USER_CREATED = "user.created" + USER_UPDATED = "user.updated" + +# Enum-based event types +class UserEventType(DomainEventType): + CREATED = "user.created" + UPDATED = "user.updated" + +# Both work the same way +event_type1: EventType = StringEvents.USER_CREATED +event_type2: EventType = UserEventType.CREATED +``` + +### 4.2 Event Model Definition + +Events are defined as Pydantic models with automatic schema registration: + +```python +from bub.events import BaseEvent, register_event + +@register_event +class UserCreatedEvent(BaseEvent): + event_type = UserEventType.CREATED + user_id: str + username: str + email: str + +@register_event +class UserUpdatedEvent(BaseEvent): + event_type = UserEventType.UPDATED + user_id: str + changes: dict[str, str] +``` + +### 4.3 Event-Driven Flow Patterns + +#### 4.3.1 Basic Event Flow + +```python +from bub.events import publish, subscribe + +# Define event types +class MockEventType(DomainEventType): + TEST_ACTION = "test.action" + TEST_RESPONSE = "test.response" + +@register_event +class MockActionEvent(BaseEvent): + event_type = MockEventType.TEST_ACTION + action: str + value: int + +@register_event +class MockResponseEvent(BaseEvent): + event_type = MockEventType.TEST_RESPONSE + response: str + value: int + +# Event handlers +@subscribe(MockEventType.TEST_ACTION) +def handle_action(event): + event_data = event.data + print(f"Processing action: {event_data['action']}") + + # Auto-generate response + response = MockResponseEvent( + response=f"Processed {event_data['action']}", + value=event_data['value'] * 2 + ) + publish(response) + +@subscribe(MockEventType.TEST_RESPONSE) +def handle_response(event): + event_data = event.data + print(f"Response: {event_data['response']}") + +# Trigger the flow +action = MockActionEvent(action="click", value=5) +publish(action) +# Output: +# Processing action: click +# Response: Processed click +``` + +#### 4.3.2 Event Cascade Pattern + +```python +@subscribe(MockEventType.TEST_ACTION) +def trigger_cascade(event): + event_data = event.data + print(f"1. Action: {event_data['action']}") + + # Trigger response + response = MockResponseEvent( + response=f"Handled {event_data['action']}", + value=event_data['value'] + ) + publish(response) + +@subscribe(MockEventType.TEST_RESPONSE) +def continue_cascade(event): + event_data = event.data + print(f"2. Response: {event_data['response']}") + + # Trigger processed + processed = MockProcessedEvent( + result=f"Completed {event_data['response']}", + value=event_data['value'] + 10 + ) + publish(processed) + +@subscribe(MockEventType.TEST_PROCESSED) +def end_cascade(event): + event_data = event.data + print(f"3. Processed: {event_data['result']}") + +# Start cascade +action = MockActionEvent(action="submit", value=10) +publish(action) +# Output: +# 1. Action: submit +# 2. Response: Handled submit +# 3. Processed: Completed Handled submit +``` + +### 4.4 Multi-Bus Communication + +The system supports communication across multiple event buses: + +```python +from bub.events import EventSystem + +# Create separate event systems for different domains +user_system = EventSystem() +data_system = EventSystem() +notification_system = EventSystem() + +cross_bus_events = [] + +# User bus handler +@user_system.subscribe(MockEventType.TEST_ACTION) +def user_handler(event): + event_data = event.data + cross_bus_events.append(f"User bus: {event_data['action']}") + + # Send to data bus + response = MockResponseEvent( + response=f"From user: {event_data['action']}", + value=event_data['value'] + ) + data_system.publish(response) + +# Data bus handler +@data_system.subscribe(MockEventType.TEST_RESPONSE) +def data_handler(event): + event_data = event.data + cross_bus_events.append(f"Data bus: {event_data['response']}") + +# Start cross-bus flow +action = MockActionEvent(action="upload_file", value=5) +user_system.publish(action) + +print(cross_bus_events) +# Output: ['User bus: upload_file', 'Data bus: From user: upload_file'] +``` + +### 4.5 Event Visualization and Analysis + +The system provides native eventure integration for powerful event analysis: + +```python +from bub.events import get_event_system + +# Get the event system and query interface +event_system = get_event_system() +event_query = event_system.get_query() + +# Generate some events +for action in ["login", "search", "logout"]: + event = MockActionEvent(action=action, value=len(action)) + publish(event) + +# Use native eventure visualization +print("Event Cascade Visualization:") +event_query.print_event_cascade() + +# Analyze specific cascades +all_events = event_query.get_root_events() +if all_events: + print(f"\nCascade for first event:") + event_query.print_single_cascade(all_events[0]) +``` + +## 5. API Reference + +### 5.1 Core Classes + +| Class | Purpose | +|-------|---------| +| `EventSystem` | Main facade for event operations | +| `BaseEvent` | Base class for all events | +| `DomainEventType` | Base for domain event types | +| `EventBusAdapter` | Abstract adapter interface | + +### 5.2 Type Definitions + +| Type | Description | +|------|-------------| +| `EventType` | `Union[str, Enum]` - Flexible event type support | +| `EventHandler` | `Callable[[Event], None]` - Event handler function | +| `Subscription` | `Callable[[], None]` - Subscription object | + +### 5.3 Global Functions + +| Function | Purpose | +|----------|---------| +| `publish(event, bus="default")` | Publish event using global system | +| `subscribe(event_type)` | Subscribe to event type using global system | +| `get_event_system()` | Get global event system instance | +| `get_bus()` | Get event bus instance | +| `get_log()` | Get event log instance | +| `get_query()` | Get query interface for analysis | + +### 5.4 Registration Functions + +| Function | Purpose | +|----------|---------| +| `@register_event` | Decorator for registering event schemas | +| `get_event_schema(event_type)` | Get schema for event type | +| `get_event_schema_or_raise(event_type)` | Get schema or raise exception | + +## 6. Best Practices + +### 6.1 Event Type Design + +```python +# Good: Use domain.action pattern +class UserEventType(DomainEventType): + CREATED = "user.created" + UPDATED = "user.updated" + DELETED = "user.deleted" + +# Avoid: Inconsistent naming +class BadEvents: + USER_CREATED = "user_created" # Use dots, not underscores + UPDATE_USER = "update_user" # Use domain.action pattern +``` + +### 6.2 Event Data Access + +```python +# Correct: Access data from eventure Event objects +@subscribe(UserEventType.CREATED) +def handle_user_created(event): + event_data = event.data + user_id = event_data['user_id'] + username = event_data['username'] + +# Wrong: Direct attribute access +@subscribe(UserEventType.CREATED) +def handle_user_created(event): + user_id = event.user_id # This won't work! +``` + +### 6.3 Event Cascade Design + +```python +# Good: Clear cascade flow +@subscribe(UserEventType.CREATED) +def handle_user_created(event): + # Process user creation + publish(UserProfileCreatedEvent(user_id=event.data['user_id'])) + +@subscribe(UserProfileEventType.CREATED) +def handle_profile_created(event): + # Send welcome notification + publish(NotificationSentEvent(user_id=event.data['user_id'])) + +# Avoid: Complex nested cascades +@subscribe(UserEventType.CREATED) +def handle_user_created(event): + # Don't create multiple cascades in one handler + publish(Event1()) + publish(Event2()) + publish(Event3()) +``` + +### 6.4 Testing + +```python +from bub.events import EventSystem, set_event_system + +class TestEventSystem: + def setup_method(self): + """Reset event system before each test.""" + set_event_system(EventSystem()) + + def test_event_flow(self): + events_received = [] + + @subscribe(MockEventType.TEST_ACTION) + def handle_action(event): + events_received.append(event.data['action']) + + publish(MockActionEvent(action="test", value=1)) + assert events_received == ["test"] +``` + +## 7. Advanced Features + +### 7.1 Custom Adapters + +```python +from bub.events.adapters import EventBusAdapter + +class CustomAdapter(EventBusAdapter): + def publish(self, event, parent_event=None, bus_name=None): + # Custom implementation + print(f"Custom publish: {event.type}") + return event + + def subscribe(self, event_type, handler, bus_name=None): + # Custom subscription logic + print(f"Custom subscribe: {event_type}") + return lambda: None # Return unsubscribe function +``` + +### 7.2 Event Type Normalization + +```python +from bub.events.types import normalize_event_type + +# All these normalize to the same string: +normalize_event_type("user.created") # "user.created" +normalize_event_type(UserEventType.CREATED) # "user.created" +normalize_event_type(StringEvents.USER_CREATED) # "user.created" +``` + +### 7.3 Domain Event Utilities + +```python +@register_event +class UserCreatedEvent(BaseEvent): + event_type = UserEventType.CREATED # "user.created" + +event = UserCreatedEvent(user_id="123") +print(event.get_domain()) # "user" +print(event.get_action()) # "created" +``` + +The Event System provides a powerful, flexible foundation for building event-driven applications with clean architecture and excellent developer experience. diff --git a/env.example b/env.example index 96708eb1..118117a8 100644 --- a/env.example +++ b/env.example @@ -22,6 +22,14 @@ BUB_API_KEY=your_api_key_here # Optional: Maximum tokens for AI responses (default varies by model) # BUB_MAX_TOKENS=4096 +# Optional: Timeout for AI responses in seconds (default: 30) +# Increase this value if you're experiencing timeout issues with complex requests +# BUB_TIMEOUT_SECONDS=60 + +# Optional: Maximum number of tool execution cycles (default: 10) +# Increase this value for complex multi-step tasks, decrease for faster responses +# BUB_MAX_ITERATIONS=15 + # Optional: Custom workspace path (default: current directory) # BUB_WORKSPACE_PATH=/path/to/your/workspace @@ -39,18 +47,3 @@ BUB_API_KEY=your_api_key_here # BUB_PROVIDER=anthropic # BUB_MODEL_NAME=claude-3-5-sonnet-20241022 # BUB_API_KEY=sk-ant-... - -# Local Ollama (no API key needed) -# BUB_PROVIDER=ollama -# BUB_MODEL_NAME=llama3 -# # BUB_API_KEY not needed - -# Groq (fast inference) -# BUB_PROVIDER=groq -# BUB_MODEL_NAME=llama3-8b-8192 -# BUB_API_KEY=gsk_... - -# Mistral AI -# BUB_PROVIDER=mistral -# BUB_MODEL_NAME=mistral-large-latest -# BUB_API_KEY=... diff --git a/examples/event-system-how-it-works.py b/examples/event-system-how-it-works.py new file mode 100644 index 00000000..116dec27 --- /dev/null +++ b/examples/event-system-how-it-works.py @@ -0,0 +1,458 @@ +"""Simple Bub Event System Examples - Core Patterns and Features. + +This module demonstrates the essential event system capabilities: +1. Core event-driven patterns +2. Multi-cross-bus communication +3. Event visualization and cascade analysis +4. Simple, user-friendly examples +""" + +from __future__ import annotations + +import time + +from bub.events import ( + BaseEvent, + EventSystem, + get_event_system, + publish, + register_event, + subscribe, +) +from bub.events.types import DomainEventType + +# ============================================================================ +# SIMPLE EVENT TYPES +# ============================================================================ + + +class SimpleEventType(DomainEventType): + """Simple event types for demonstration.""" + + USER_ACTION = "user.action" + SYSTEM_RESPONSE = "system.response" + DATA_PROCESSED = "data.processed" + NOTIFICATION_SENT = "notification.sent" + + +@register_event +class UserActionEvent(BaseEvent): + """User performs an action.""" + + event_type = SimpleEventType.USER_ACTION + action: str + user_id: str + timestamp: float + + +@register_event +class SystemResponseEvent(BaseEvent): + """System responds to user action.""" + + event_type = SimpleEventType.SYSTEM_RESPONSE + response: str + user_id: str + timestamp: float + + +@register_event +class DataProcessedEvent(BaseEvent): + """Data has been processed.""" + + event_type = SimpleEventType.DATA_PROCESSED + data_type: str + result: str + timestamp: float + + +@register_event +class NotificationSentEvent(BaseEvent): + """Notification was sent.""" + + event_type = SimpleEventType.NOTIFICATION_SENT + message: str + recipient: str + timestamp: float + + +# ============================================================================ +# CORE EVENT-DRIVEN PATTERNS +# ============================================================================ + + +def demonstrate_basic_event_flow(): + """Demonstrate basic event-driven communication.""" + print("\nBasic Event Flow") + print("=" * 30) + + # Track events + events_log = [] + + @subscribe(SimpleEventType.USER_ACTION) + def handle_user_action(event): + # Access event data from eventure Event object + event_data = event.data + events_log.append(f"User action: {event_data['action']}") + + # System responds automatically + response = SystemResponseEvent( + response=f"Processed: {event_data['action']}", user_id=event_data["user_id"], timestamp=time.time() + ) + publish(response) + + @subscribe(SimpleEventType.SYSTEM_RESPONSE) + def handle_system_response(event): + event_data = event.data + events_log.append(f"System response: {event_data['response']}") + + # User performs action + user_action = UserActionEvent(action="click_button", user_id="user123", timestamp=time.time()) + + print("User clicks button...") + publish(user_action) + + # Show event flow + for event in events_log: + print(event) + + print("Event-driven flow completed!") + + +def demonstrate_event_cascade(): + """Demonstrate how events cascade through the system.""" + print("\nEvent Cascade") + print("=" * 30) + + cascade_log = [] + + @subscribe(SimpleEventType.USER_ACTION) + def trigger_cascade(event): + event_data = event.data + cascade_log.append(f"1. User action: {event_data['action']}") + + # Trigger data processing + data_event = DataProcessedEvent( + data_type="user_input", result=f"Processed {event_data['action']}", timestamp=time.time() + ) + publish(data_event) + + @subscribe(SimpleEventType.DATA_PROCESSED) + def handle_data_processed(event): + event_data = event.data + cascade_log.append(f"2. Data processed: {event_data['result']}") + + # Trigger notification + notification = NotificationSentEvent( + message=f"Your {event_data['data_type']} was processed", recipient="user123", timestamp=time.time() + ) + publish(notification) + + @subscribe(SimpleEventType.NOTIFICATION_SENT) + def handle_notification(event): + event_data = event.data + cascade_log.append(f"3. Notification sent: {event_data['message']}") + + # Start the cascade + print("User performs action...") + user_action = UserActionEvent(action="submit_form", user_id="user123", timestamp=time.time()) + publish(user_action) + + # Show cascade + for step in cascade_log: + print(step) + + print("Event cascade completed!") + + +# ============================================================================ +# MULTI-CROSS-BUS COMMUNICATION +# ============================================================================ + + +def demonstrate_multi_bus_communication(): + """Demonstrate communication across multiple event buses.""" + print("\nMulti-Bus Communication") + print("=" * 40) + + # Create separate event systems for different domains + user_system = EventSystem() + data_system = EventSystem() + notification_system = EventSystem() + + # Track cross-bus communication + cross_bus_log = [] + + # User bus handlers + @user_system.subscribe(SimpleEventType.USER_ACTION) + def handle_user_action(event): + event_data = event.data + cross_bus_log.append(f"User bus: {event_data['action']}") + + # Send to data bus + data_event = DataProcessedEvent( + data_type="user_action", result=f"Processing {event_data['action']}", timestamp=time.time() + ) + data_system.publish(data_event) + + # Data bus handlers + @data_system.subscribe(SimpleEventType.DATA_PROCESSED) + def handle_data_processed(event): + event_data = event.data + cross_bus_log.append(f"Data bus: {event_data['result']}") + + # Send to notification bus + notification = NotificationSentEvent( + message=f"Data processed: {event_data['result']}", recipient="user123", timestamp=time.time() + ) + notification_system.publish(notification) + + # Notification bus handlers + @notification_system.subscribe(SimpleEventType.NOTIFICATION_SENT) + def handle_notification(event): + event_data = event.data + cross_bus_log.append(f"Notification bus: {event_data['message']}") + + # Start cross-bus flow + print("Starting cross-bus communication...") + user_action = UserActionEvent(action="upload_file", user_id="user123", timestamp=time.time()) + user_system.publish(user_action) + + # Show cross-bus flow + for step in cross_bus_log: + print(step) + + print("Multi-bus communication completed!") + + +def demonstrate_single_system_multi_bus(): + """Demonstrate multiple buses within a single event system.""" + print("\nSingle System Multi-Bus Communication") + print("=" * 45) + + # Create a single event system with multiple buses + event_system = EventSystem() + + # Create additional buses within the same system + event_system.create_bus("user") + event_system.create_bus("data") + event_system.create_bus("notification") + + # Track multi-bus communication + multi_bus_log = [] + + # Subscribe to events on different buses within the same system + @event_system.subscribe(SimpleEventType.USER_ACTION, bus="user") + def handle_user_action(event): + event_data = event.data + multi_bus_log.append(f"User bus: {event_data['action']}") + + # Publish to data bus within the same system + data_event = DataProcessedEvent( + data_type="user_action", result=f"Processing {event_data['action']}", timestamp=time.time() + ) + event_system.publish(data_event, bus="data") + + @event_system.subscribe(SimpleEventType.DATA_PROCESSED, bus="data") + def handle_data_processed(event): + event_data = event.data + multi_bus_log.append(f"Data bus: {event_data['result']}") + + # Publish to notification bus within the same system + notification = NotificationSentEvent( + message=f"Data processed: {event_data['result']}", recipient="user123", timestamp=time.time() + ) + event_system.publish(notification, bus="notification") + + @event_system.subscribe(SimpleEventType.NOTIFICATION_SENT, bus="notification") + def handle_notification(event): + event_data = event.data + multi_bus_log.append(f"Notification bus: {event_data['message']}") + + # Start multi-bus flow within single system + print("Starting single system multi-bus communication...") + user_action = UserActionEvent(action="process_data", user_id="user123", timestamp=time.time()) + event_system.publish(user_action, bus="user") + + # Show multi-bus flow + for step in multi_bus_log: + print(step) + + print("Single system multi-bus communication completed!") + + # Demonstrate bus isolation + print("\nBus Isolation Test:") + isolation_log = [] + + @event_system.subscribe(SimpleEventType.USER_ACTION, bus="default") + def handle_default_bus(event): + isolation_log.append("Default bus handler triggered") + + @event_system.subscribe(SimpleEventType.USER_ACTION, bus="user") + def handle_user_bus(event): + isolation_log.append("User bus handler triggered") + + # Publish to user bus only + event_system.publish(UserActionEvent(action="test", user_id="user123", timestamp=time.time()), bus="user") + + print("Bus isolation results:") + for log in isolation_log: + print(f" - {log}") + + +# ============================================================================ +# EVENT VISUALIZATION AND ANALYSIS +# ============================================================================ + + +def demonstrate_event_visualization(): + """Demonstrate event visualization using native eventure features.""" + print("\nEvent Visualization (Native Eventure)") + print("=" * 45) + + # Get the event system and its underlying eventure components + event_system = get_event_system() + event_query = event_system.get_query() + + # Generate events for visualization using default bus + print("Generating events for visualization...") + + actions = ["login", "search", "download", "logout"] + for action in actions: + # User action events + user_action = UserActionEvent(action=action, user_id="user123", timestamp=time.time()) + publish(user_action) + time.sleep(0.1) + + # System response events + response = SystemResponseEvent(response=f"Processed: {action}", user_id="user123", timestamp=time.time()) + publish(response) + time.sleep(0.1) + + # Data processed events + data_event = DataProcessedEvent(data_type="user_input", result=f"Processed {action}", timestamp=time.time()) + publish(data_event) + time.sleep(0.1) + + # Notification events + notification = NotificationSentEvent( + message="Your user_input was processed", recipient="user123", timestamp=time.time() + ) + publish(notification) + time.sleep(0.1) + + # Use only native eventure visualization + print("\nNative Eventure Visualization:") + print("-" * 50) + event_query.print_event_cascade() + + +def demonstrate_cascade_analysis(): + """Demonstrate cascade analysis using native eventure features.""" + print("\nCascade Analysis (Native Eventure)") + print("=" * 40) + + # Get eventure components + event_system = get_event_system() + event_query = event_system.get_query() + + # Generate cascades for analysis + print("Generating event cascades for analysis...") + + # Cascade 1: Simple action + user_action = UserActionEvent(action="click_button", user_id="user123", timestamp=time.time()) + publish(user_action) + + response = SystemResponseEvent(response="Button clicked", user_id="user123", timestamp=time.time()) + publish(response) + + notification = NotificationSentEvent(message="Action completed", recipient="user123", timestamp=time.time()) + publish(notification) + + # Cascade 2: Complex action + user_action2 = UserActionEvent(action="upload_file", user_id="user123", timestamp=time.time()) + publish(user_action2) + + response2 = SystemResponseEvent(response="File received", user_id="user123", timestamp=time.time()) + publish(response2) + + data_processed = DataProcessedEvent( + data_type="file_upload", result="File processed successfully", timestamp=time.time() + ) + publish(data_processed) + + notification2 = NotificationSentEvent( + message="File uploaded and processed", recipient="user123", timestamp=time.time() + ) + publish(notification2) + + # Use only native eventure cascade analysis + print("\nNative Eventure Cascade Analysis:") + print("-" * 50) + + # Get all events and show cascades + all_events = event_query.get_root_events() + if all_events: + # Show cascade for the first event + event_query.print_single_cascade(all_events[0]) + + # Show cascade for a middle event if available + if len(all_events) > 2: + print("\nCascade for middle event:") + event_query.print_single_cascade(all_events[len(all_events) // 2]) + + # Show cascade for the last event + print("\nCascade for last event:") + event_query.print_single_cascade(all_events[-1]) + + +# ============================================================================ +# SIMPLE INTEGRATION EXAMPLE +# ============================================================================ + + +def demonstrate_simple_integration(): + """Demonstrate simple integration of all features.""" + print("\nSimple Integration Example") + print("=" * 40) + + print("Starting integrated event system demo...") + + # 1. Basic event flow + demonstrate_basic_event_flow() + + # 2. Event cascade + demonstrate_event_cascade() + + # 3. Multi-bus communication + demonstrate_multi_bus_communication() + + # 4. Single system multi-bus communication + demonstrate_single_system_multi_bus() + + # 5. Event visualization + demonstrate_event_visualization() + + # 6. Cascade analysis + demonstrate_cascade_analysis() + + print("\nAll demonstrations completed!") + print("Event system is working correctly!") + + +# ============================================================================ +# MAIN DEMONSTRATION +# ============================================================================ + + +def run_simple_demo(): + """Run the simple event system demonstration.""" + print("Bub Event System - Simple Examples") + print("=" * 50) + print("This demo shows core event-driven patterns and features.") + print() + + demonstrate_simple_integration() + + +if __name__ == "__main__": + run_simple_demo() diff --git a/mkdocs.yml b/mkdocs.yml index d7f7cd33..192b8f08 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,8 @@ copyright: Maintained by Chojan Shang. nav: - Home: index.md + - Internals: + - Event System Architecture: internals/event-system-architecture.md - Posts: - Baby Bub - Bootstrap Milestone: posts/2025-07-16-baby-bub-bootstrap-milestone.md diff --git a/pyproject.toml b/pyproject.toml index 793adf06..fec60016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,11 @@ description = "Bub it. Build it." authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] readme = "README.md" keywords = ['python'] -requires-python = ">=3.11,<4.0" +requires-python = ">=3.12,<4.0" classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", @@ -22,6 +21,8 @@ dependencies = [ "typer>=0.9.0", "any-llm-sdk[openai,anthropic,google,azure,aws]>=0.1.0", "rich>=13.0.0", + "eventure>=0.4.4", + "logfire>=4.0.0", ] [project.urls] @@ -58,6 +59,7 @@ paths = ["src"] [tool.mypy] files = ["src"] +mypy_path = ["stubs"] disallow_untyped_defs = true disallow_any_unimported = true no_implicit_optional = true diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 3c547b36..f78fa0db 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,7 +1,8 @@ """Bub - Bub it. Build it.""" -__version__ = "0.1.0" +from .core import Agent, AgentContext +from .core.tools import ToolRegistry -from .agent import Agent, ToolRegistry +__version__ = "0.1.0" -__all__ = ["Agent", "ToolRegistry"] +__all__ = ["Agent", "AgentContext", "ToolRegistry"] diff --git a/src/bub/agent/__init__.py b/src/bub/agent/__init__.py deleted file mode 100644 index 4482d1da..00000000 --- a/src/bub/agent/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Agent package for Bub.""" - -from .context import Context -from .core import Agent, ReActPromptFormatter -from .tools import Tool, ToolExecutor, ToolRegistry, ToolResult - -__all__ = [ - "Agent", - "Context", - "ReActPromptFormatter", - "Tool", - "ToolExecutor", - "ToolRegistry", - "ToolResult", -] diff --git a/src/bub/agent/context.py b/src/bub/agent/context.py deleted file mode 100644 index 436a56ea..00000000 --- a/src/bub/agent/context.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Context for the agent package.""" - -from pathlib import Path -from typing import Any, Optional - -from ..config import get_settings - - -class Context: - """Agent environment context: workspace, config, tool registry, etc.""" - - def __init__(self, workspace_path: Optional[Path] = None, config: Optional[Any] = None): - self.workspace_path = workspace_path or Path.cwd() - self.config = config or get_settings(self.workspace_path) - self.tool_registry = None # Will be set by Agent - - def get_system_prompt(self) -> str: - """Get the system prompt from config.""" - return self.config.system_prompt or "" - - def build_context_message(self) -> str: - """Build a clean context message with essential information.""" - if not self.tool_registry: - return f"[Environment Context]\nWorkspace: {self.workspace_path}\nNo tools available" - - tool_schemas = self.tool_registry.get_tool_schemas() - msg = [ - "[Environment Context]", - f"Workspace: {self.workspace_path}", - f"Available tools: {', '.join(tool_schemas.keys())}", - f"Tool schemas: {self.tool_registry._format_schemas_for_context()}", - ] - return "\n".join(msg) diff --git a/src/bub/agent/core.py b/src/bub/agent/core.py deleted file mode 100644 index 25fd19a6..00000000 --- a/src/bub/agent/core.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Core agent implementation for Bub.""" - -from pathlib import Path -from typing import Callable, Optional - -from any_llm import completion # type: ignore[import-untyped] -from openai.types.chat import ChatCompletion, ChatCompletionMessageParam - -from .context import Context -from .tools import ToolExecutor, ToolRegistry - - -class ReActPromptFormatter: - """Formats ReAct prompts by combining principles, system prompt, and examples.""" - - REACT_PRINCIPLES = """You are an AI assistant with access to tools. When you need to use a tool, follow this format: - -Thought: Do I need to use a tool? Yes/No. If yes, which one and with what input? -Action: -Action Input: - -After the tool is executed, you will see: -Observation: - -You can use multiple Thought/Action/Action Input/Observation steps as needed (ReAct pattern). When you have a final answer, reply with: - -Final Answer: - -If you do not need a tool, just reply with Final Answer.""" - - REACT_EXAMPLE = """Example: -Thought: I need to list files in the workspace. -Action: run_command -Action Input: {"command": "ls"} -Observation: -Thought: Now I can answer the user. -Final Answer: The files in your workspace are ... - -Available tools and their parameters will be provided in the context. - -Always be helpful, accurate, and follow best practices.""" - - def format_prompt(self, system_prompt: str) -> str: - """Format a complete ReAct prompt with principles, system prompt, and examples.""" - return f"{self.REACT_PRINCIPLES}\n\n{system_prompt}\n\n{self.REACT_EXAMPLE}" - - -class Agent: - """Main AI agent for Bub.""" - - def __init__( - self, - provider: str, - model_name: str, - api_key: str, - api_base: Optional[str] = None, - max_tokens: Optional[int] = None, - workspace_path: Optional[Path] = None, - system_prompt: Optional[str] = None, - ): - self.provider = provider - self.model_name = model_name - self.api_key = api_key - self.api_base = api_base - self.max_tokens = max_tokens - self.conversation_history: list[ChatCompletionMessageParam] = [] - - # Initialize context and tool registry - self.context: Context = Context(workspace_path=workspace_path) - self.tool_registry: ToolRegistry = ToolRegistry() - self.tool_registry.register_default_tools() - self.context.tool_registry = self.tool_registry # type: ignore[assignment] - - self.tool_executor = ToolExecutor(self.context) - - # Store custom system prompt if provided - self.custom_system_prompt = system_prompt - - self.prompt_formatter = ReActPromptFormatter() - # Use format_prompt to generate the full system prompt - if self.custom_system_prompt: - self.system_prompt = self.prompt_formatter.format_prompt(self.custom_system_prompt) - else: - # Use config default if not provided - config_prompt = self.context.get_system_prompt() - self.system_prompt = self.prompt_formatter.format_prompt(config_prompt) - - @property - def model(self) -> str: - """Get the full model string in provider/model format.""" - return f"{self.provider}/{self.model_name}" - - def reset_conversation(self) -> None: - """Reset the conversation history.""" - self.conversation_history = [] - - def chat(self, message: str, on_step: Optional[Callable[[str, str], None]] = None) -> str: - """Chat with the agent. If on_step is provided, call it with each intermediate message/observation.""" - self.conversation_history.append({"role": "user", "content": message}) - - while True: - context_msg = self.context.build_context_message() - - messages: list[ChatCompletionMessageParam] = [ - {"role": "system", "content": self.system_prompt}, - {"role": "system", "content": context_msg}, - ] - messages.extend(self.conversation_history) - - try: - response: ChatCompletion = completion( - model=self.model, - messages=messages, - max_tokens=self.max_tokens, - api_key=self.api_key, - api_base=self.api_base, - ) - assistant_message = str(response.choices[0].message.content) - self.conversation_history.append({"role": "assistant", "content": assistant_message}) - if on_step: - on_step("assistant", assistant_message) - - tool_calls = self.tool_executor.extract_tool_calls(assistant_message) - - if tool_calls: - for tool_call in tool_calls: - tool_name = tool_call.get("tool") - parameters = tool_call.get("parameters", {}) - if not tool_name: - continue - result = self.tool_executor.execute_tool(tool_name, **parameters) - observation = f"Observation: {result.format_result()}" - self.conversation_history.append({"role": "user", "content": observation}) - if on_step: - on_step("observation", observation) - continue - else: - return assistant_message - - except Exception as e: - error_message = f"Error communicating with AI: {e!s}" - self.conversation_history.append({"role": "assistant", "content": error_message}) - if on_step: - on_step("error", error_message) - return error_message diff --git a/src/bub/cli/__init__.py b/src/bub/cli/__init__.py index dbbae569..83615045 100644 --- a/src/bub/cli/__init__.py +++ b/src/bub/cli/__init__.py @@ -1,5 +1,11 @@ -"""CLI package for Bub.""" +"""CLI module with domain-driven event architecture.""" from .app import app +from .controller import CLIDomain +from .render import UIDomain -__all__ = ["app"] +__all__ = [ + "CLIDomain", + "UIDomain", + "app", +] diff --git a/src/bub/cli/app.py b/src/bub/cli/app.py index b4c28861..326f0b0c 100644 --- a/src/bub/cli/app.py +++ b/src/bub/cli/app.py @@ -1,13 +1,17 @@ -"""CLI main module for Bub.""" +"""CLI main module for Bub using domain-driven event architecture.""" from pathlib import Path -from typing import Optional +from typing import Callable, Optional import typer -from ..agent import Agent -from ..config import Settings, get_settings -from .render import create_cli_renderer +from bub.cli.controller import CLIDomain +from bub.cli.render import UIDomain +from bub.config import Settings, get_settings, read_bubmd +from bub.core.agent import Agent +from bub.events.bridges import EventSystemDomainBridge + +_settings = get_settings() app = typer.Typer( name="bub", @@ -24,36 +28,19 @@ def main_callback(ctx: typer.Context) -> None: chat() -renderer = create_cli_renderer() +def _create_domains() -> tuple[CLIDomain, UIDomain]: + """Create CLI and UI domains with event bridge.""" + bridge = EventSystemDomainBridge() + cli_domain = CLIDomain(bridge) + ui_domain = UIDomain(bridge) + return cli_domain, ui_domain -def _exit_with_error(message: str) -> None: - """Exit with error message.""" - renderer.error(message) +def _exit_with_error() -> None: + """Exit with error code.""" raise typer.Exit(1) -def _validate_workspace(workspace_path: Path) -> None: - """Validate workspace directory exists.""" - if not workspace_path.exists(): - _exit_with_error(f"Workspace directory does not exist: {workspace_path}") - - -def _validate_api_key(settings: Settings) -> None: - """Validate API key is present.""" - if not settings.api_key: - renderer.api_key_error() - raise typer.Exit(1) - - -def _validate_model_config(settings: Settings) -> None: - """Validate provider and model configuration.""" - if not settings.provider: - _exit_with_error("Provider not configured. Set BUB_PROVIDER (e.g., 'openai', 'anthropic', 'ollama')") - if not settings.model_name: - _exit_with_error("Model name not configured. Set BUB_MODEL_NAME (e.g., 'gpt-4', 'claude-3', 'llama2')") - - def _create_agent( settings: Settings, workspace_path: Path, model_override: Optional[str], max_tokens: Optional[int] ) -> Agent: @@ -69,6 +56,12 @@ def _create_agent( provider = settings.provider or "openai" model_name = settings.model_name or "gpt-3.5-turbo" + system_prompt = ( + settings.system_prompt + "\n" + read_bubmd(workspace_path) + if settings.system_prompt + else read_bubmd(workspace_path) + ) + return Agent( provider=provider, model_name=model_name, @@ -76,53 +69,98 @@ def _create_agent( api_base=settings.api_base, max_tokens=max_tokens or settings.max_tokens, workspace_path=workspace_path, - system_prompt=settings.system_prompt, + system_prompt=system_prompt, + config=get_settings(workspace_path), + timeout_seconds=settings.timeout_seconds, + max_iterations=settings.max_iterations, ) -def _handle_special_commands(user_input: str, agent: Agent) -> Optional[bool]: +def _handle_special_commands(user_input: str, agent: Agent, cli_domain: CLIDomain) -> Optional[bool]: + """Handle special commands through CLI domain events.""" cmd = user_input.lower() if cmd in ["quit", "exit", "q"]: - renderer.info("Goodbye!") + # Publish quit command event - UI will handle the display + cli_domain.handle_user_input(user_input, command="quit") + cli_domain.end_chat("user_exit") return True # break elif cmd == "reset": agent.reset_conversation() - renderer.conversation_reset() + # Publish reset command event - UI will handle the display + cli_domain.handle_user_input(user_input, command="reset") + cli_domain.publish_conversation_reset() return False # continue elif cmd == "debug": - renderer.toggle_debug() + # Publish debug command event - UI will handle the toggle + cli_domain.handle_user_input(user_input, command="debug") + cli_domain.toggle_debug_mode() return False # continue return None # not a special command -def _handle_chat_loop(agent: Agent) -> None: - """Handle the interactive chat loop.""" +def _create_step_handler(cli_domain: CLIDomain) -> Callable[[str, str], None]: + """Create a step handler function for the agent.""" + + def on_step(kind: str, content: str) -> None: + # Publish step events through CLI domain + if kind == "assistant": + cli_domain.publish_assistant_message(content) + elif kind == "observation": + cli_domain.publish_observation_message(content) + elif kind == "error": + cli_domain.publish_error_message(content) + elif kind == "taao_thought": + cli_domain.publish_taao_message("thought", content) + elif kind == "taao_action": + cli_domain.publish_taao_message("action", content) + elif kind == "taao_action_input": + cli_domain.publish_taao_message("action_input", content) + elif kind == "taao_observation": + cli_domain.publish_taao_message("observation", content) + + return on_step + + +def _handle_chat_loop(agent: Agent, cli_domain: CLIDomain, ui_domain: UIDomain) -> None: + """Handle the interactive chat loop using direct method calls.""" while True: try: - user_input = renderer.get_user_input() + # Get user input through UI domain + user_input = ui_domain.get_user_input() if not user_input.strip(): continue - special = _handle_special_commands(user_input, agent) + + special = _handle_special_commands(user_input, agent, cli_domain) if special is True: break if special is False: continue - def on_step(kind: str, content: str) -> None: - if kind == "assistant": - renderer.assistant_message(content) - elif kind == "observation": - # Pass observation through assistant_message to apply TAAO filtering - renderer.assistant_message(content) - elif kind == "error": - renderer.error(content) + # Handle user input through CLI domain + cli_domain.handle_user_input(user_input) - agent.chat(user_input, on_step=on_step) - renderer.info("") + # Create step handler and execute agent chat + on_step = _create_step_handler(cli_domain) + try: + agent.chat(user_input, on_step=on_step, debug_mode=cli_domain.debug_mode) + cli_domain.publish_info_message("") + except Exception as e: + # Handle agent-specific errors + error_msg = f"Agent error: {e!s}" + cli_domain.publish_error_message(error_msg) + cli_domain.handle_error(error_msg, "agent_error", {"exception_type": type(e).__name__}) except (KeyboardInterrupt, EOFError): - renderer.info("\nGoodbye!") + cli_domain.publish_info_message("\nGoodbye!") + cli_domain.end_chat("keyboard_interrupt") break + except Exception as e: + # Handle unexpected errors in the chat loop + error_msg = f"Unexpected error in chat loop: {e!s}" + cli_domain.publish_error_message(error_msg) + cli_domain.handle_error(error_msg, "chat_loop_error", {"exception_type": type(e).__name__}) + # Continue the loop instead of breaking to allow recovery + continue @app.command() @@ -133,33 +171,80 @@ def chat( ) -> None: """Start interactive chat with Bub.""" try: + # Create domains + cli_domain, ui_domain = _create_domains() + workspace_path = workspace or Path.cwd() - _validate_workspace(workspace_path) + + # Validate workspace through CLI domain + if not cli_domain.validate_workspace(workspace_path): + _exit_with_error() settings = get_settings(workspace_path) - _validate_api_key(settings) - _validate_model_config(settings) + + # Validate configuration through CLI domain + if not cli_domain.validate_api_key(settings.api_key): + _exit_with_error() + if not cli_domain.validate_model_config(settings.provider, settings.model_name): + _exit_with_error() agent = _create_agent(settings, workspace_path, model, max_tokens) - renderer.welcome() - renderer.usage_info( + # Start chat session through CLI domain + cli_domain.start_chat( + workspace_path=workspace_path, + model=agent.model or "", + tools=agent.tool_registry.list_tools(), + ) + + # Display UI through CLI domain events + cli_domain.publish_welcome_message() + cli_domain.publish_usage_info( workspace_path=str(workspace_path), - model=agent.model or "", # ensure str + model=agent.model or "", tools=agent.tool_registry.list_tools(), ) - renderer.info("Type 'quit', 'exit', or 'q' to end the session.") - renderer.info("Type 'reset' to clear conversation history.") - renderer.info("Type 'debug' to toggle TAAO process visibility.") - renderer.info("") - _handle_chat_loop(agent) + _handle_chat_loop(agent, cli_domain, ui_domain) except Exception as e: - renderer.error(f"Failed to start chat: {e!s}") + # Create domains for error handling if not already created + try: + cli_domain, ui_domain = _create_domains() + except Exception: + # Fallback to basic error handling + raise typer.Exit(1) from e + + cli_domain.publish_error_message(f"Failed to start chat: {e!s}") + cli_domain.handle_error( + f"Failed to start chat: {e!s}", + "startup_error", + {"exception_type": type(e).__name__}, + ) raise typer.Exit(1) from e +def _setup_run_environment( + workspace: Optional[Path], model: Optional[str], max_tokens: Optional[int] +) -> tuple[Agent, CLIDomain, UIDomain]: + """Setup environment for run command.""" + cli_domain, ui_domain = _create_domains() + workspace_path = workspace or Path.cwd() + + if not cli_domain.validate_workspace(workspace_path): + _exit_with_error() + + settings = get_settings(workspace_path) + + if not cli_domain.validate_api_key(settings.api_key): + _exit_with_error() + if not cli_domain.validate_model_config(settings.provider, settings.model_name): + _exit_with_error() + + agent = _create_agent(settings, workspace_path, model, max_tokens) + return agent, cli_domain, ui_domain + + @app.command() def run( command: str, @@ -169,30 +254,30 @@ def run( ) -> None: """Run a single command with Bub.""" try: - workspace_path = workspace or Path.cwd() - _validate_workspace(workspace_path) + agent, cli_domain, ui_domain = _setup_run_environment(workspace, model, max_tokens) - settings = get_settings(workspace_path) - _validate_api_key(settings) - _validate_model_config(settings) - - agent = _create_agent(settings, workspace_path, model, max_tokens) + # Execute command through CLI domain + cli_domain.execute_command(command) + cli_domain.publish_info_message(f"Executing: {command}") - renderer.info(f"Executing: {command}") - - def on_step(kind: str, content: str) -> None: - if kind == "assistant": - renderer.assistant_message(content) - elif kind == "observation": - # Pass observation through assistant_message to apply TAAO filtering - renderer.assistant_message(content) - elif kind == "error": - renderer.error(content) - - agent.chat(command, on_step=on_step) + # Create step handler and execute agent chat + on_step = _create_step_handler(cli_domain) + agent.chat(command, on_step=on_step, debug_mode=cli_domain.debug_mode) except Exception as e: - renderer.error(f"Failed to execute command: {e!s}") + # Create domains for error handling if not already created + try: + cli_domain, ui_domain = _create_domains() + except Exception: + # Fallback to basic error handling + raise typer.Exit(1) from e + + cli_domain.publish_error_message(f"Failed to execute command: {e!s}") + cli_domain.handle_error( + f"Failed to execute command: {e!s}", + "execution_error", + {"command": command, "exception_type": type(e).__name__}, + ) raise typer.Exit(1) from e diff --git a/src/bub/cli/controller.py b/src/bub/cli/controller.py new file mode 100644 index 00000000..f48e2785 --- /dev/null +++ b/src/bub/cli/controller.py @@ -0,0 +1,298 @@ +"""CLI domain using the bridge pattern for event-driven architecture.""" + +from pathlib import Path +from typing import Any, Optional + +from bub.events.bridges import BaseDomain, DomainEventBridge + +from .events import ( + ChatEndedEvent, + ChatStartedEvent, + CLIConversationResetRequestedEvent, + CLIDebugToggleRequestedEvent, + CLIMessageRequestedEvent, + CLIUsageInfoRequestedEvent, + CLIWelcomeRequestedEvent, + CommandExecutedEvent, + ErrorOccurredEvent, + UIEventType, + UserInputEvent, + UserInputRequestedEvent, +) + + +class CLIDomain(BaseDomain): + """CLI domain that manages CLI state and events.""" + + # Error messages + NO_WORKSPACE_ERROR = "No workspace set" + + def __init__(self, bridge: DomainEventBridge) -> None: + """Initialize CLI domain. + + Args: + bridge: Event bridge for communication + """ + super().__init__(bridge, "cli") + self._current_workspace: Optional[Path] = None + self._current_model: Optional[str] = None + self._current_tools: list[str] = [] + self._chat_active: bool = False + self._debug_mode: bool = False + + def _setup_subscriptions(self) -> None: + """Setup event subscriptions for CLI domain.""" + # Subscribe to UI events that affect CLI state + self.subscribe(UIEventType.DEBUG_TOGGLED, self._handle_debug_toggled) + self.subscribe(UIEventType.CONVERSATION_RESET, self._handle_conversation_reset) + self.subscribe(UIEventType.USER_INPUT_RECEIVED, self._handle_user_input_received) + + def _handle_debug_toggled(self, event: Any) -> None: + """Handle debug mode toggle event.""" + self._debug_mode = getattr(event, "enabled", False) + self.update_state({"debug_mode": self._debug_mode}) + + def _handle_conversation_reset(self, event: Any) -> None: + """Handle conversation reset event.""" + self._chat_active = False + self.update_state({"chat_active": False}) + + def _handle_user_input_received(self, event: Any) -> None: + """Handle user input received event.""" + # Store the user input for retrieval + input_text = getattr(event, "input_text", "") + self.update_state({"last_user_input": input_text}) + + def start_chat(self, workspace_path: Path, model: str, tools: list[str]) -> None: + """Start a chat session.""" + self._current_workspace = workspace_path + self._current_model = model + self._current_tools = tools + self._chat_active = True + + self.update_state({ + "workspace_path": str(workspace_path), + "model": model, + "tools": tools, + "chat_active": True, + }) + + # Publish chat started event + self.publish( + ChatStartedEvent( + workspace_path=str(workspace_path), + model=model, + tools=tools, + ) + ) + + def end_chat(self, reason: str = "user_exit") -> None: + """End the current chat session.""" + self._chat_active = False + self.update_state({"chat_active": False}) + + # Publish chat ended event + self.publish(ChatEndedEvent(reason=reason)) + + def handle_user_input(self, input_text: str, command: Optional[str] = None) -> None: + """Handle user input.""" + # Publish user input event + self.publish( + UserInputEvent( + input_text=input_text, + command=command, + workspace_path=str(self._current_workspace) if self._current_workspace else None, + ) + ) + + def request_user_input(self) -> str: + """Request user input through UI domain.""" + # Publish user input request event + self.publish(UserInputRequestedEvent()) + + # Fallback to direct prompt since UI domain access is not working + from rich.prompt import Prompt + + return Prompt.ask("[bold green]You[/bold green]") + + def execute_command(self, command: str) -> None: + """Execute a command.""" + if not self._current_workspace: + raise ValueError(self.NO_WORKSPACE_ERROR) + + # Publish command executed event + self.publish( + CommandExecutedEvent( + command=command, + workspace_path=str(self._current_workspace), + model=self._current_model or "unknown", + ) + ) + + def handle_error(self, error_message: str, error_type: str, context: Optional[dict[str, Any]] = None) -> None: + """Handle an error occurrence.""" + # Publish error occurred event + self.publish( + ErrorOccurredEvent( + error_message=error_message, + error_type=error_type, + context=context, + ) + ) + + # UI Message Publishing Methods + def publish_info_message(self, content: str) -> None: + """Publish info message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="info", + content=content, + ) + ) + + def publish_error_message(self, content: str) -> None: + """Publish error message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="error", + content=content, + ) + ) + + def publish_success_message(self, content: str) -> None: + """Publish success message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="success", + content=content, + ) + ) + + def publish_warning_message(self, content: str) -> None: + """Publish warning message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="warning", + content=content, + ) + ) + + def publish_assistant_message(self, content: str) -> None: + """Publish assistant message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="assistant", + content=content, + ) + ) + + def publish_observation_message(self, content: str) -> None: + """Publish observation message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type="observation", + content=content, + ) + ) + + def publish_taao_message(self, taao_type: str, content: str) -> None: + """Publish TAAO message request event.""" + self.publish( + CLIMessageRequestedEvent( + message_type=f"taao_{taao_type}", + content=content, + ) + ) + + def publish_welcome_message(self) -> None: + """Publish welcome message request event.""" + self.publish(CLIWelcomeRequestedEvent()) + + def publish_usage_info(self, workspace_path: str, model: str, tools: list[str]) -> None: + """Publish usage info request event.""" + self.publish( + CLIUsageInfoRequestedEvent( + workspace_path=workspace_path, + model=model, + tools=tools, + ) + ) + + def publish_conversation_reset(self) -> None: + """Publish conversation reset request event.""" + self.publish(CLIConversationResetRequestedEvent()) + + def publish_debug_toggle(self, enabled: bool) -> None: + """Publish debug toggle request event.""" + self.publish(CLIDebugToggleRequestedEvent(enabled=enabled)) + + def toggle_debug_mode(self) -> None: + """Toggle debug mode and publish event.""" + self._debug_mode = not self._debug_mode + self.update_state({"debug_mode": self._debug_mode}) + self.publish_debug_toggle(self._debug_mode) + + def validate_workspace(self, workspace_path: Path) -> bool: + """Validate workspace directory exists.""" + if not workspace_path.exists(): + self.handle_error( + f"Workspace directory does not exist: {workspace_path}", + "validation_error", + {"workspace_path": str(workspace_path)}, + ) + return False + return True + + def validate_api_key(self, api_key: Optional[str]) -> bool: + """Validate API key is present.""" + if not api_key: + self.handle_error( + "API key not found", + "configuration_error", + {"missing": "api_key"}, + ) + return False + return True + + def validate_model_config(self, provider: Optional[str], model_name: Optional[str]) -> bool: + """Validate provider and model configuration.""" + if not provider: + self.handle_error( + "Provider not configured. Set BUB_PROVIDER (e.g., 'openai', 'anthropic', 'ollama')", + "configuration_error", + {"missing": "provider"}, + ) + return False + if not model_name: + self.handle_error( + "Model name not configured. Set BUB_MODEL_NAME (e.g., 'gpt-4', 'claude-3', 'llama2')", + "configuration_error", + {"missing": "model_name"}, + ) + return False + return True + + @property + def current_workspace(self) -> Optional[Path]: + """Get current workspace path.""" + return self._current_workspace + + @property + def current_model(self) -> Optional[str]: + """Get current model name.""" + return self._current_model + + @property + def current_tools(self) -> list[str]: + """Get current tools list.""" + return self._current_tools.copy() + + @property + def chat_active(self) -> bool: + """Check if chat is currently active.""" + return self._chat_active + + @property + def debug_mode(self) -> bool: + """Check if debug mode is enabled.""" + return self._debug_mode diff --git a/src/bub/cli/events.py b/src/bub/cli/events.py new file mode 100644 index 00000000..3df15a3b --- /dev/null +++ b/src/bub/cli/events.py @@ -0,0 +1,228 @@ +"""CLI event definitions for the domain-driven event architecture.""" + +from typing import Any, Optional + +from bub.events.models import BaseEvent +from bub.events.registry import register_event +from bub.events.types import DomainEventType + +# ============================================================================ +# CLI EVENT TYPES +# ============================================================================ + + +class CLIEventType(DomainEventType): + """CLI event types.""" + + USER_INPUT = "cli.user_input" + CHAT_STARTED = "cli.chat_started" + CHAT_ENDED = "cli.chat_ended" + COMMAND_EXECUTED = "cli.command_executed" + ERROR_OCCURRED = "cli.error_occurred" + MESSAGE_REQUESTED = "cli.message_requested" + WELCOME_REQUESTED = "cli.welcome_requested" + USAGE_INFO_REQUESTED = "cli.usage_info_requested" + CONVERSATION_RESET_REQUESTED = "cli.conversation_reset_requested" + DEBUG_TOGGLE_REQUESTED = "cli.debug_toggle_requested" + + +class UIEventType(DomainEventType): + """UI event types.""" + + MESSAGE_RENDERED = "ui.message_rendered" + DEBUG_TOGGLED = "ui.debug_toggled" + CONVERSATION_RESET = "ui.conversation_reset" + USER_INPUT_RECEIVED = "ui.user_input_received" + WELCOME_DISPLAYED = "ui.welcome_displayed" + USAGE_INFO_DISPLAYED = "ui.usage_info_displayed" + USER_INPUT_REQUESTED = "ui.user_input_requested" + TAAO_MESSAGE_RENDERED = "ui.taao_message_rendered" + + +# ============================================================================ +# CLI EVENTS (CLI domain publishes these) +# ============================================================================ + + +@register_event +class UserInputEvent(BaseEvent): + """Event emitted when user provides input.""" + + event_type = CLIEventType.USER_INPUT + + input_text: str + command: Optional[str] = None + workspace_path: Optional[str] = None + + +@register_event +class ChatStartedEvent(BaseEvent): + """Event emitted when chat session starts.""" + + event_type = CLIEventType.CHAT_STARTED + + workspace_path: str + model: str + tools: list[str] + + +@register_event +class ChatEndedEvent(BaseEvent): + """Event emitted when chat session ends.""" + + event_type = CLIEventType.CHAT_ENDED + + reason: str + + +@register_event +class CommandExecutedEvent(BaseEvent): + """Event emitted when a command is executed.""" + + event_type = CLIEventType.COMMAND_EXECUTED + + command: str + workspace_path: str + model: str + + +@register_event +class ErrorOccurredEvent(BaseEvent): + """Event emitted when an error occurs.""" + + event_type = CLIEventType.ERROR_OCCURRED + + error_message: str + error_type: str + context: Optional[dict[str, Any]] = None + + +# ============================================================================ +# CLI REQUEST EVENTS (CLI domain publishes these, UI domain subscribes) +# ============================================================================ + + +@register_event +class CLIMessageRequestedEvent(BaseEvent): + """Event emitted when CLI requests a message to be rendered.""" + + event_type = CLIEventType.MESSAGE_REQUESTED + + message_type: str # "info", "success", "error", "warning", "assistant" + content: str + + +@register_event +class CLIWelcomeRequestedEvent(BaseEvent): + """Event emitted when CLI requests welcome message.""" + + event_type = CLIEventType.WELCOME_REQUESTED + + +@register_event +class CLIUsageInfoRequestedEvent(BaseEvent): + """Event emitted when CLI requests usage info display.""" + + event_type = CLIEventType.USAGE_INFO_REQUESTED + + workspace_path: str + model: str + tools: list[str] + + +@register_event +class CLIConversationResetRequestedEvent(BaseEvent): + """Event emitted when CLI requests conversation reset.""" + + event_type = CLIEventType.CONVERSATION_RESET_REQUESTED + + +@register_event +class CLIDebugToggleRequestedEvent(BaseEvent): + """Event emitted when CLI requests debug toggle.""" + + event_type = CLIEventType.DEBUG_TOGGLE_REQUESTED + + enabled: bool + + +# ============================================================================ +# UI EVENTS (UI domain publishes these) +# ============================================================================ + + +@register_event +class MessageRenderedEvent(BaseEvent): + """Event emitted when a message is rendered.""" + + event_type = UIEventType.MESSAGE_RENDERED + + message_type: str # "info", "success", "error", "warning", "user", "assistant" + content: str + debug_mode: bool = False + + +@register_event +class DebugToggledEvent(BaseEvent): + """Event emitted when debug mode is toggled.""" + + event_type = UIEventType.DEBUG_TOGGLED + + enabled: bool + + +@register_event +class ConversationResetEvent(BaseEvent): + """Event emitted when conversation is reset.""" + + event_type = UIEventType.CONVERSATION_RESET + + +@register_event +class UserInputReceivedEvent(BaseEvent): + """Event emitted when user input is received.""" + + event_type = UIEventType.USER_INPUT_RECEIVED + + input_text: str + debug_mode: bool = False + + +@register_event +class WelcomeDisplayedEvent(BaseEvent): + """Event emitted when welcome message is displayed.""" + + event_type = UIEventType.WELCOME_DISPLAYED + + message: str + + +@register_event +class UsageInfoDisplayedEvent(BaseEvent): + """Event emitted when usage information is displayed.""" + + event_type = UIEventType.USAGE_INFO_DISPLAYED + + workspace_path: Optional[str] = None + model: str = "" + tools: Optional[list[str]] = None + + +@register_event +class UserInputRequestedEvent(BaseEvent): + """Event emitted when user input is requested.""" + + event_type = UIEventType.USER_INPUT_REQUESTED + + prompt: str = "[bold cyan]You[/bold cyan]" + + +@register_event +class TAAOMessageRenderedEvent(BaseEvent): + """Event emitted when TAAO process message is rendered.""" + + event_type = UIEventType.TAAO_MESSAGE_RENDERED + + taao_type: str # "thought", "action", "action_input", "observation" + content: str + debug_mode: bool diff --git a/src/bub/cli/render.py b/src/bub/cli/render.py index c5095e9a..9a275dad 100644 --- a/src/bub/cli/render.py +++ b/src/bub/cli/render.py @@ -1,196 +1,373 @@ -"""CLI renderer for Bub.""" +"""UI domain using the bridge pattern for event-driven architecture.""" -import re -from typing import Optional +from typing import Any from rich.console import Console +from rich.panel import Panel from rich.prompt import Prompt - - -class Renderer: - """CLI renderer using Rich for beautiful terminal output.""" - - def __init__(self) -> None: - self.console: Console = Console() - self._show_debug: bool = False - - def toggle_debug(self) -> None: - """Toggle debug mode to show/hide TAAO process.""" - self._show_debug = not self._show_debug - status = "enabled" if self._show_debug else "disabled" - self.console.print(f"[dim]🔧 Debug mode {status}[/dim]") - - def info(self, message: str) -> None: - """Render an info message.""" - self.console.print(message) - - def success(self, message: str) -> None: - """Render a success message.""" - self.console.print(f"[green]{message}[/green]") - - def error(self, message: str) -> None: - """Render an error message.""" - self.console.print(f"[bold red]Error:[/bold red] {message}") - - def warning(self, message: str) -> None: - """Render a warning message.""" - self.console.print(f"[yellow]{message}[/yellow]") - - def welcome(self, message: str = "[bold blue]Bub[/bold blue] - Bub it. Build it.") -> None: - """Render welcome message.""" - self.console.print(message) - - def usage_info(self, workspace_path: Optional[str] = None, model: str = "", tools: Optional[list] = None) -> None: - """Render usage information.""" - if workspace_path: - from ..tools.utils import sanitize_path - - display_path = sanitize_path(workspace_path) - self.console.print(f"[bold]Working directory:[/bold] [cyan]{display_path}[/cyan]") - if model: - self.console.print(f"[bold]Model:[/bold] [magenta]{model}[/magenta]") - if tools: - self.console.print(f"[bold]Available tools:[/bold] [green]{', '.join(tools)}[/green]") - - def user_message(self, message: str) -> None: - """Render user message.""" - self.console.print(f"[bold cyan]You:[/bold cyan] {message}") - - def assistant_message(self, message: str) -> None: - """Render assistant message with smart formatting.""" - # Check if this is a TAAO process message - if self._is_taao_message(message): - if self._show_debug: - self._render_taao_message(message) - else: - self._render_taao_minimal(message) - return - - # Check if this is a final answer - if self._is_final_answer(message): - self._render_final_answer(message) +from rich.rule import Rule +from rich.table import Table +from rich.text import Text + +from bub.events.bridges import BaseDomain, DomainEventBridge + +from .events import ( + CLIEventType, + ConversationResetEvent, + DebugToggledEvent, + MessageRenderedEvent, + UIEventType, + UserInputReceivedEvent, +) + + +class UIDomain(BaseDomain): + """UI domain that manages rendering and user interaction.""" + + def __init__(self, bridge: DomainEventBridge) -> None: + """Initialize UI domain. + + Args: + bridge: Event bridge for communication + """ + super().__init__(bridge, "ui") + self._console = Console(markup=True, highlight=True) + self._prompt = Prompt() + self._debug_mode: bool = False + self._conversation_count: int = 0 + + def _setup_subscriptions(self) -> None: + """Setup event subscriptions for UI domain.""" + # Subscribe to CLI events to react to state changes + self.subscribe(CLIEventType.CHAT_STARTED, self._handle_chat_started) + self.subscribe(CLIEventType.USER_INPUT, self._handle_user_input) + self.subscribe(CLIEventType.COMMAND_EXECUTED, self._handle_command_executed) + self.subscribe(CLIEventType.ERROR_OCCURRED, self._handle_error_occurred) + + # Subscribe to CLI message requests (CLI domain publishes these) + self.subscribe(CLIEventType.MESSAGE_REQUESTED, self._handle_message_requested) + self.subscribe(CLIEventType.WELCOME_REQUESTED, self._handle_welcome_requested) + self.subscribe(CLIEventType.USAGE_INFO_REQUESTED, self._handle_usage_info_requested) + self.subscribe(CLIEventType.CONVERSATION_RESET_REQUESTED, self._handle_conversation_reset_requested) + self.subscribe(CLIEventType.DEBUG_TOGGLE_REQUESTED, self._handle_debug_toggle_requested) + self.subscribe(UIEventType.USER_INPUT_REQUESTED, self._handle_user_input_requested) + + def _handle_chat_started(self, event: Any) -> None: + """Handle chat started event.""" + pass + + def _handle_user_input(self, event: Any) -> None: + """Handle user input event.""" + pass + + def _handle_command_executed(self, event: Any) -> None: + """Handle command executed event.""" + pass + + def _handle_error_occurred(self, event: Any) -> None: + """Handle error occurred event.""" + pass + + def _handle_message_rendered(self, event: Any) -> None: + """Handle message rendered event.""" + message_type = getattr(event, "message_type", "info") + content = getattr(event, "content", "") + _debug_mode = getattr(event, "debug_mode", False) + + self.render(message_type, content=content) + + def _handle_welcome_displayed(self, event: Any) -> None: + """Handle welcome displayed event.""" + message = getattr(event, "message", "Welcome to Bub!") + self._console.print("[bold blue]" + message + "[/bold blue]") + + def _handle_usage_info_displayed(self, event: Any) -> None: + """Handle usage info displayed event.""" + workspace_path = getattr(event, "workspace_path", "") + model = getattr(event, "model", "") + tools = getattr(event, "tools", []) + + self._console.print(f"[dim]Workspace:[/dim] {workspace_path}") + self._console.print(f"[dim]Model:[/dim] {model}") + self._console.print(f"[dim]Tools:[/dim] {', '.join(tools) if tools else 'None'}") + + def _handle_conversation_reset(self, event: Any) -> None: + """Handle conversation reset event.""" + self._conversation_count = 0 + self._console.print("[yellow]Conversation history cleared.[/yellow]") + + def _handle_debug_toggled(self, event: Any) -> None: + """Handle debug toggle event.""" + enabled = getattr(event, "enabled", False) + self._debug_mode = enabled + status = "enabled" if enabled else "disabled" + self._console.print(f"[yellow]Debug mode {status}.[/yellow]") + + def _handle_message_requested(self, event: Any) -> None: + """Handle message requested event.""" + event_data = event.data + message_type = event_data.get("message_type", "info") + content = event_data.get("content", "") + + self.render(message_type, content=content) + + def _handle_welcome_requested(self, event: Any) -> None: + """Handle welcome requested event.""" + self._render_welcome_panel() + + def _handle_usage_info_requested(self, event: Any) -> None: + """Handle usage info requested event.""" + event_data = event.data + workspace_path = event_data.get("workspace_path", "") + model = event_data.get("model", "") + tools = event_data.get("tools", []) + + self._render_usage_info(workspace_path, model, tools) + + def _handle_conversation_reset_requested(self, event: Any) -> None: + """Handle conversation reset requested event.""" + self.conversation_reset() + + def _handle_debug_toggle_requested(self, event: Any) -> None: + """Handle debug toggle requested event.""" + event_data = event.data + enabled = event_data.get("enabled", False) + self._debug_mode = enabled + + # Display the status change + status = "enabled" if enabled else "disabled" + status_color = "green" if enabled else "red" + self._console.print(f"[{status_color}]Debug mode {status}.[/{status_color}]") + + def _handle_user_input_requested(self, event: Any) -> None: + """Handle user input requested event.""" + event_data = event.data + prompt = event_data.get("prompt", "[bold cyan]You[/bold cyan]") + self._console.print(f"\n{prompt}: ", end="") + + def _publish_message_event(self, message_type: str, content: str, **kwargs: Any) -> None: + """Publish message rendered event.""" + self.publish( + MessageRenderedEvent( + message_type=message_type, + content=content, + debug_mode=self._debug_mode, + ) + ) + + def render(self, event_type: str, **kwargs: Any) -> None: + """Render event based on type.""" + if event_type == "info": + self._render_info(kwargs.get("content", "")) + elif event_type == "success": + self._render_success(kwargs.get("content", "")) + elif event_type == "error": + self._render_error(kwargs.get("content", "")) + elif event_type == "warning": + self._render_warning(kwargs.get("content", "")) + elif event_type == "assistant": + self._render_assistant_message(kwargs.get("content", "")) + elif event_type == "observation": + self._render_observation_message(kwargs.get("content", "")) + elif event_type.startswith("taao_"): + taao_type = event_type.replace("taao_", "") + self._render_react_step(taao_type, kwargs.get("content", "")) + else: + self._render_generic(event_type, kwargs) + + def _render_info(self, content: str) -> None: + """Render info message with improved styling.""" + if content.strip(): + self._console.print(f"[blue]INFO:[/blue] {content}") + + def _render_success(self, content: str) -> None: + """Render success message with improved styling.""" + if content.strip(): + self._console.print(f"[green]SUCCESS:[/green] {content}") + + def _render_error(self, content: str) -> None: + """Render error message with improved styling.""" + if content.strip(): + self._console.print(f"[red]ERROR:[/red] {content}") + + def _render_warning(self, content: str) -> None: + """Render warning message with improved styling.""" + if content.strip(): + self._console.print(f"[yellow]WARNING:[/yellow] {content}") + + def _render_assistant_message(self, content: str) -> None: + """Render assistant message with enhanced styling.""" + # Increment conversation count for better UX + self._conversation_count += 1 + + # Create a panel for the assistant message + assistant_text = Text(content, style="white") + panel = Panel( + assistant_text, + title="[bold green]Bub[/bold green]", + title_align="left", + border_style="green", + padding=(1, 2), + highlight=True, + ) + self._console.print(panel) + + def _render_observation_message(self, content: str) -> None: + """Render observation message with improved styling.""" + if content.strip(): + self._console.print(f"[dim]OBSERVATION:[/dim] [dim]{content}[/dim]") + + def _render_react_step(self, step_type: str, content: str) -> None: + """Render ReAct step with subtle styling for debug mode.""" + if not self._debug_mode: return - # Regular assistant message - self.console.print(f"[bold yellow]Bub:[/bold yellow] {message}") - - def _is_taao_message(self, message: str) -> bool: - """Check if message is part of TAAO process.""" - taao_patterns = [ - r"Thought:", - r"Action:", - r"Action Input:", - r"Observation:", - ] - return any(re.search(pattern, message, re.IGNORECASE) for pattern in taao_patterns) - - def _is_final_answer(self, message: str) -> bool: - """Check if message is a final answer.""" - return re.search(r"Final Answer:", message, re.IGNORECASE) is not None - - def _render_taao_message(self, message: str) -> None: - """Render TAAO process message in debug mode.""" - if "Thought:" in message: - self.console.print(f"[dim]💭 {message}[/dim]") - elif "Action:" in message: - self.console.print(f"[dim]🔧 {message}[/dim]") - elif "Action Input:" in message: - self.console.print(f"[dim]📝 {message}[/dim]") - elif "Observation:" in message: - # Make Observation even more subtle in debug mode - # Extract just the key information from observation - if "Output:" in message: - output_match = re.search(r"Output:\s*(.+)", message, re.DOTALL) - if output_match: - output = output_match.group(1).strip() - self.console.print(f"[dim]👁️ Output: {output}[/dim]") - else: - self.console.print(f"[dim]👁️ {message}[/dim]") - elif "Error:" in message: - error_match = re.search(r"Error:\s*(.+)", message, re.DOTALL) - if error_match: - error = error_match.group(1).strip() - self.console.print(f"[dim]👁️ Error: {error}[/dim]") - else: - self.console.print(f"[dim]👁️ {message}[/dim]") - else: - self.console.print(f"[dim]👁️ {message}[/dim]") + # Define subtle colors and titles for different step types + step_config = { + "thought": {"color": "blue", "title": "THOUGHT", "style": "dim"}, + "action": {"color": "yellow", "title": "ACTION", "style": "dim"}, + "action_input": {"color": "dim", "title": "INPUT", "style": "dim"}, + "observation": {"color": "green", "title": "OBSERVATION", "style": "dim"}, + } + + config = step_config.get(step_type, {"color": "white", "title": step_type.upper(), "style": "dim"}) + + # Use simple text instead of panels for debug steps to make them less prominent + self._console.print( + f"[{config['color']}]{config['title']}:[/{config['color']}] [{config['style']}]{content.strip()}[/{config['style']}]" + ) + + def _render_generic(self, event_type: str, kwargs: dict[str, Any]) -> None: + """Render generic event.""" + content = kwargs.get("content", str(kwargs)) + self._console.print(f"[white]{event_type}:[/white] {content}") + + def _render_welcome_panel(self) -> None: + """Render welcome panel with enhanced design.""" + # Create a beautiful banner with slogan + banner_text = Text( + "╭─ Bub ──────────────────────────────────────────────────────────────────────────────────────────────╮", + style="bold cyan", + ) + slogan_text = Text( + "│ │", + style="cyan", + ) + title_text = Text( + "│ Bub it. Build it. │", + style="bold white", + ) + subtitle_text = Text( + "│ Your AI-powered assistant │", + style="dim white", + ) + bottom_text = Text( + "│ │", + style="cyan", + ) + footer_text = Text( + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + style="bold cyan", + ) + + # Print the banner + self._console.print(banner_text) + self._console.print(slogan_text) + self._console.print(title_text) + self._console.print(subtitle_text) + self._console.print(bottom_text) + self._console.print(footer_text) + self._console.print("") + + def _render_usage_info(self, workspace_path: str, model: str, tools: list[str]) -> None: + """Render usage information with enhanced design.""" + # Create a table for better organization + table = Table(show_header=False, box=None, padding=(0, 1)) + table.add_column("Key", style="dim", width=12) + table.add_column("Value", style="white") + + table.add_row("Workspace", workspace_path) + table.add_row("Model", model) + table.add_row("Tools", ", ".join(tools) if tools else "None") + + self._console.print(table) + + # Add elegant commands section + self._console.print("") + self._console.print("[bold cyan]Quick Commands:[/bold cyan]") + + # Create a compact commands table + commands_table = Table(show_header=False, box=None, padding=(0, 2)) + commands_table.add_column("Command", style="bold green", width=12) + commands_table.add_column("Description", style="dim") + + commands_table.add_row("quit/exit/q", "End the session") + commands_table.add_row("reset", "Clear conversation history") + commands_table.add_row("debug", "Toggle ReAct process visibility") + + self._console.print(commands_table) + self._console.print("") + + def get_user_input(self) -> str: + """Get user input from prompt with enhanced styling.""" + try: + # Create a styled prompt + prompt_text = Text("You", style="bold green") + user_input = self._prompt.ask(prompt_text) + + if user_input.strip(): + self.publish( + UserInputReceivedEvent( + input_text=user_input, + debug_mode=self._debug_mode, + ) + ) + except (KeyboardInterrupt, EOFError): + return "quit" else: - self.console.print(f"[dim]{message}[/dim]") - - def _render_taao_minimal(self, message: str) -> None: - """Render minimal TAAO process message in normal mode.""" - # In normal mode, only show Action, hide everything else - if "Action:" in message: - action_match = re.search(r"Action:\s*(.+)", message, re.IGNORECASE) - if action_match: - action = action_match.group(1).strip() - self.console.print(f"[dim]🔧 {action}[/dim]") - # Hide Thought, Action Input, and Observation in normal mode - - def _render_final_answer(self, message: str) -> None: - """Render final answer in a natural way.""" - # Extract the actual answer content - match = re.search(r"Final Answer:\s*(.+)", message, re.IGNORECASE | re.DOTALL) - if match: - answer = match.group(1).strip() - - # Clean up common redundant phrases - answer = re.sub(r"^The (command|output) .*? (was|is):\s*", "", answer, flags=re.IGNORECASE) - answer = re.sub(r"^The result is:\s*", "", answer, flags=re.IGNORECASE) - answer = re.sub( - r"^The .*? executed successfully and produced the output:\s*", "", answer, flags=re.IGNORECASE - ) - answer = re.sub( - r"^The .*? command was executed, and it displayed the output:\s*", "", answer, flags=re.IGNORECASE - ) - answer = re.sub( - r"^The .*? command has been executed again, and the output is:\s*", "", answer, flags=re.IGNORECASE - ) - answer = re.sub( - r"^The .*? command was executed successfully, and it displayed the output:\s*", - "", - answer, - flags=re.IGNORECASE, - ) + return user_input - # Remove backticks and extra formatting - answer = re.sub(r"`([^`]+)`", r"\1", answer) - - if answer and answer.strip(): - self.console.print(f"[bold yellow]Bub:[/bold yellow] {answer}") - else: - self.console.print("[bold yellow]Bub:[/bold yellow] Done!") - else: - # Fallback to original message - self.console.print(f"[bold yellow]Bub:[/bold yellow] {message}") + def assistant_message(self, content: str) -> None: + """Display assistant message.""" + self.render("assistant", content=content) def conversation_reset(self) -> None: - """Render conversation reset message.""" - self.console.print("[green]Conversation history cleared.[/green]") - - def api_key_error(self) -> None: - """Render API key error with helpful information.""" - self.error("API key not found") - self.console.print("") - self.info("Quick fix:") - self.console.print(' export BUB_API_KEY="your-key-here"') - self.console.print("") - self.info("Get API keys from:") - self.console.print(" - Anthropic: https://console.anthropic.com/") - self.console.print(" - OpenAI: https://platform.openai.com/") - self.console.print(" - Google: https://aistudio.google.com/") - - def get_user_input(self, prompt: str = "[bold cyan]You[/bold cyan]") -> str: - """Get user input with styled prompt.""" - return Prompt.ask(prompt) - - -def create_cli_renderer() -> Renderer: - """Create a CLI renderer.""" - return Renderer() - - -def get_user_input(prompt: str = "[bold cyan]You[/bold cyan]") -> str: - """Get user input with styled prompt.""" - return Prompt.ask(prompt) + """Handle conversation reset with enhanced feedback.""" + self._conversation_count = 0 + self._console.print(Rule("[yellow]Conversation Reset[/yellow]", style="yellow")) + self.publish(ConversationResetEvent()) + + def toggle_debug(self) -> None: + """Toggle debug mode with enhanced feedback.""" + self._debug_mode = not self._debug_mode + status = "enabled" if self._debug_mode else "disabled" + status_color = "green" if self._debug_mode else "red" + + self._console.print(f"[{status_color}]Debug mode {status}.[/{status_color}]") + self.publish(DebugToggledEvent(enabled=self._debug_mode)) + + def info(self, content: str) -> None: + """Display info message.""" + self._render_info(content) + + def error(self, content: str) -> None: + """Display error message.""" + self._render_error(content) + + def success(self, content: str) -> None: + """Display success message.""" + self._render_success(content) + + def warning(self, content: str) -> None: + """Display warning message.""" + self._render_warning(content) + + @property + def debug_mode(self) -> bool: + """Get debug mode status.""" + return self._debug_mode + + @property + def console(self) -> Console: + """Get console instance.""" + return self._console diff --git a/src/bub/config.py b/src/bub/config.py index 615db1ce..eb78a88a 100644 --- a/src/bub/config.py +++ b/src/bub/config.py @@ -4,66 +4,63 @@ from typing import Optional from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings + +from .utils.logging import configure_logfire class Settings(BaseSettings): - """Bub application settings.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - env_prefix="BUB_", - extra="ignore", # Ignore extra fields from old config - ) - - # Any-LLM settings - provider: Optional[str] = Field(default=None, description="LLM provider (e.g., openai, anthropic, ollama)") - model_name: Optional[str] = Field(default=None, description="Model name from the provider") - api_key: Optional[str] = Field(default=None, description="API key for the model provider") - api_base: Optional[str] = Field(default=None, description="Custom API base URL") - max_tokens: Optional[int] = Field(default=None, description="Maximum tokens for AI responses") - - # Agent settings - system_prompt: Optional[str] = Field( - default="""You are Bub, a helpful AI assistant. You can: -- Read and edit files -- Run terminal commands -- Help with code development - -You have access to various tools to help with coding tasks. Use them when needed to accomplish the user's requests. - -Always be helpful, accurate, and follow best practices.""", - description="System prompt for the AI agent", - ) - - # Tool settings - workspace_path: Optional[str] = Field(default=None, description="Workspace path for file operations") - - -def read_bub_md(workspace_path: Optional[Path] = None) -> Optional[str]: - """Read BUB.md file from workspace if it exists.""" - if workspace_path is None: - workspace_path = Path.cwd() - - bub_md_path = workspace_path / "BUB.md" - if bub_md_path.exists() and bub_md_path.is_file(): - try: - return bub_md_path.read_text(encoding="utf-8") - except Exception: - # If we can't read the file, return None - return None - return None + """Application settings.""" + + # API Configuration + api_key: Optional[str] = Field(None, description="API key for the LLM provider") + provider: Optional[str] = Field(None, description="LLM provider (e.g., 'openai', 'anthropic')") + model_name: Optional[str] = Field(None, description="Model name (e.g., 'gpt-4', 'claude-3')") + api_base: Optional[str] = Field(None, description="Optional API base URL") + max_tokens: int = Field(default=4000, description="Maximum tokens for responses") + + # Agent Configuration + timeout_seconds: int = Field(default=30, description="Timeout for AI responses in seconds") + max_iterations: int = Field(default=10, description="Maximum number of tool execution cycles") + + # System Configuration + system_prompt: Optional[str] = Field(None, description="System prompt for the agent") + workspace_path: Optional[Path] = Field(None, description="Workspace directory path") + + # Logging Configuration + log_level: str = Field(default="INFO", description="Log level") + log_format: str = Field(default="text", description="Log format") + + class Config: + """Pydantic configuration.""" + + env_prefix = "BUB_" + case_sensitive = False + env_file = ".env" + env_file_encoding = "utf-8" + + +def read_bubmd(workspace_path: Path) -> str: + """Read the bubmd file from the workspace path.""" + bubmd_path = workspace_path / "bub.md" + if not bubmd_path.exists(): + return "" + with open(bubmd_path, encoding="utf-8") as file: + return file.read() def get_settings(workspace_path: Optional[Path] = None) -> Settings: - """Get application settings, with optional BUB.md system prompt override.""" - settings = Settings() + """Get application settings. + + Args: + workspace_path: Optional workspace path override + + Returns: + Settings instance + """ + # Create settings instance - pydantic-settings will automatically load from .env file + settings = Settings(workspace_path=workspace_path) # type: ignore[call-arg] - # Check for BUB.md file and use it as system prompt if available - bub_md_content = read_bub_md(workspace_path) - if bub_md_content: - settings.system_prompt = bub_md_content.strip() + configure_logfire(settings.log_level, settings.log_format) return settings diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py new file mode 100644 index 00000000..747330b7 --- /dev/null +++ b/src/bub/core/__init__.py @@ -0,0 +1,6 @@ +"""Core module for Bub.""" + +from .agent import Agent +from .context import AgentContext + +__all__ = ["Agent", "AgentContext"] diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py new file mode 100644 index 00000000..471a0eb9 --- /dev/null +++ b/src/bub/core/agent.py @@ -0,0 +1,437 @@ +"""Core agent implementation for Bub.""" + +import contextlib +import re +from pathlib import Path +from typing import Any, Callable, Optional + +from any_llm import completion # type: ignore[import-untyped] +from openai.types.chat import ChatCompletion, ChatCompletionMessageParam + +from .context import AgentContext +from .tools import ToolRegistry + + +class ReActPromptFormatter: + """Formats ReAct prompts by combining principles, system prompt, and examples.""" + + REACT_PRINCIPLES = """You are an AI assistant with access to tools. When you need to use a tool, follow this format: + +Thought: Do I need to use a tool? Yes/No. If yes, which one and with what input? +Action: +Action Input: + +After the tool is executed, you will see: +Observation: + +You can use multiple Thought/Action/Action Input/Observation steps as needed for complex reasoning. When you have a final answer, reply with: + +Final Answer: + +CRITICAL RULES: +1. If you need a tool, provide ONLY the Thought/Action/Action Input in your response +2. After seeing the Observation, provide your Final Answer +3. Do NOT include Final Answer in the same response as Action/Action Input +4. Only continue with more Thought/Action steps if you need additional tools or information +5. When the user asks for specific information (files, command output, etc.), your Final Answer MUST include the actual data +6. NEVER give generic responses like "Here's the file listing:" - ALWAYS include the actual content +7. If the user asks for file listings, show the actual files. If they ask for command output, show the actual output. +8. ALWAYS end your response with "Final Answer:" when providing the final response to the user +9. If you see tool output in the Observation, include that output in your Final Answer + +If you do not need a tool, just reply with Final Answer.""" + + REACT_EXAMPLE = """Example: + +Step 1 - Tool Usage: +Thought: I need to list files in the workspace. +Action: run_command +Action Input: {"command": "ls"} + +Step 2 - After Observation: +Observation: STDOUT: +file1.txt +file2.py +README.md + +Return code: 0 +Thought: The user asked for a file listing, so I must provide the actual files in my final answer. +Final Answer: Here are the files in your workspace: + +file1.txt +file2.py +README.md + +Available tools and their parameters will be provided in the context. + +Always be helpful, accurate, and follow best practices.""" + + def format_prompt(self, system_prompt: str) -> str: + """Format a complete ReAct prompt with principles, system prompt, and examples.""" + return f"{self.REACT_PRINCIPLES}\n\n{system_prompt}\n\n{self.REACT_EXAMPLE}" + + +class Agent: + """Main AI agent for Bub with ReAct pattern support.""" + + def __init__( + self, + provider: str, + model_name: str, + api_key: str, + api_base: Optional[str] = None, + max_tokens: Optional[int] = None, + workspace_path: Optional[Path] = None, + system_prompt: Optional[str] = None, + config: Optional[Any] = None, + timeout_seconds: int = 30, + max_iterations: int = 10, + ) -> None: + """Initialize the agent. + + Args: + provider: LLM provider (e.g., 'openai', 'anthropic') + model_name: Model name (e.g., 'gpt-4', 'claude-3') + api_key: API key for the provider + api_base: Optional API base URL + max_tokens: Maximum tokens for responses + workspace_path: Path to workspace + system_prompt: System prompt for the agent + config: Configuration object + timeout_seconds: Timeout for AI responses in seconds + max_iterations: Maximum number of tool execution cycles + """ + self.provider = provider + self.model = model_name + self.api_key = api_key + self.api_base = api_base + self.max_tokens = max_tokens + self.workspace_path = workspace_path or Path.cwd() + self.conversation_history: list[ChatCompletionMessageParam] = [] + self.timeout_seconds = timeout_seconds + self.max_iterations = max_iterations + + # Initialize components + self.context = AgentContext( + provider=provider, + model_name=model_name, + api_key=api_key, + api_base=api_base, + max_tokens=max_tokens, + system_prompt=system_prompt, + workspace_path=self.workspace_path, + ) + self.tool_registry = ToolRegistry(workspace_path=self.workspace_path) + + # Set the tool registry in the context so it's available for context building + self.context.tool_registry = self.tool_registry + + self.prompt_formatter = ReActPromptFormatter() + + full_system_prompt = self.context.get_system_prompt() + self.system_prompt = self.prompt_formatter.format_prompt(full_system_prompt) + + def chat(self, message: str, on_step: Optional[Callable[[str, str], None]] = None, debug_mode: bool = False) -> str: + """Chat with the agent using ReAct pattern. + + Args: + message: User message + on_step: Optional callback for each step + debug_mode: Whether to show ReAct process details + + Returns: + Agent response + """ + self.conversation_history.append({"role": "user", "content": message}) + + if on_step: + on_step("assistant", "Processing your request...") + + # Add loop control to prevent infinite loops + iteration_count = 0 + + while iteration_count < self.max_iterations: + try: + iteration_count += 1 + + if on_step and iteration_count > 1: + on_step("observation", f"Processing step {iteration_count}...") + + assistant_message = self._get_ai_response(on_step, debug_mode) + + # Extract and execute tool calls + tool_calls = self._extract_tool_calls(assistant_message) + has_final_answer = "Final Answer:" in assistant_message + + if tool_calls: + # Execute tools first + self._execute_tool_calls(tool_calls, on_step) + + # If there's also a Final Answer in this response, extract and return it + if has_final_answer: + final_answer = self._extract_final_answer(assistant_message) + return final_answer + + # Continue loop for next AI response after tool execution + continue + else: + # No tools, just return the response (with or without Final Answer) + if debug_mode: + # In debug mode, we already emitted the steps, so return the extracted final answer + final_answer = self._extract_final_answer(assistant_message) + return final_answer + else: + # In non-debug mode, return the already extracted final answer + return assistant_message + + except Exception as e: + error_message = f"Error communicating with AI (iteration {iteration_count}): {e!s}" + self.conversation_history.append({"role": "assistant", "content": error_message}) + if on_step: + on_step("error", error_message) + return error_message + + # If we reach here, we've exceeded max iterations + error_message = f"The AI has reached the maximum number of steps ({self.max_iterations}) while processing your request. This might indicate a complex task that requires more steps, or the AI might be stuck in a loop. You can try:\n\n1. Breaking down your request into smaller parts\n2. Being more specific about what you need\n3. Checking if your request is clear and achievable" + self.conversation_history.append({"role": "assistant", "content": error_message}) + if on_step: + on_step("error", error_message) + return error_message + + def _get_ai_response(self, on_step: Optional[Callable[[str, str], None]], debug_mode: bool) -> str: + """Get AI response and handle debug mode.""" + import signal + + context_message = self.context.build_context_message() + messages: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": self.system_prompt}, + {"role": "system", "content": context_message}, + ] + messages.extend(self.conversation_history) + + if on_step: + on_step("observation", f"Using {self.model}...") + + # Add timeout protection with graceful handling + timeout_occurred = False + + def timeout_handler(signum: int, frame: Any) -> None: + nonlocal timeout_occurred + timeout_occurred = True + + # Set up timeout (only on Unix-like systems) + try: + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(self.timeout_seconds) + except (AttributeError, OSError): + # Windows doesn't support SIGALRM, skip timeout + pass + + try: + response: ChatCompletion = completion( + model=f"{self.provider}/{self.model}", + messages=messages, + max_tokens=self.max_tokens, + api_key=self.api_key, + api_base=self.api_base, + ) + except Exception: + # Clear the alarm first + with contextlib.suppress(AttributeError, OSError): + signal.alarm(0) + + # Check if it was a timeout + if timeout_occurred: + timeout_message = f"The AI is taking longer than {self.timeout_seconds} seconds to respond. This might be due to a complex request or network issues. Please try again with a simpler request or check your connection." + self.conversation_history.append({"role": "assistant", "content": timeout_message}) + if on_step: + on_step("error", timeout_message) + return timeout_message + else: + # Re-raise other exceptions + raise + finally: + # Clear the alarm + with contextlib.suppress(AttributeError, OSError): + signal.alarm(0) + + assistant_message = str(response.choices[0].message.content) + self.conversation_history.append({"role": "assistant", "content": assistant_message}) + + # Check if this response contains tool calls + tool_calls = self._extract_tool_calls(assistant_message) + + if debug_mode: + self._emit_react_steps(assistant_message, on_step) + elif not tool_calls: + # In non-debug mode, only emit assistant message if no tools are being used + final_answer = self._extract_final_answer(assistant_message) + if on_step: + on_step("assistant", final_answer) + + return assistant_message + + def _execute_tool_calls( + self, tool_calls: list[dict[str, Any]], on_step: Optional[Callable[[str, str], None]] + ) -> None: + """Execute tool calls and add observations to conversation.""" + for tool_call in tool_calls: + tool_name = tool_call.get("tool") + parameters = tool_call.get("parameters", {}) + + if not tool_name: + continue + + result = self._execute_tool(tool_name, **parameters) + observation = f"Observation: {result}" + + self.conversation_history.append({"role": "user", "content": observation}) + if on_step: + on_step("observation", observation) + + def _extract_tool_calls(self, message: str) -> list[dict[str, Any]]: + """Extract tool calls from ReAct format message.""" + tool_calls = [] + + # Look for Action: and Action Input: patterns + action_match = re.search(r"Action:\s*(\w+)", message, re.IGNORECASE) + action_input_match = re.search(r"Action Input:\s*(.+)", message, re.IGNORECASE | re.DOTALL) + + if action_match and action_input_match: + tool_name = action_match.group(1).strip() + action_input = action_input_match.group(1).strip() + + # Clean up the action input - remove any trailing content + if "\n" in action_input: + action_input = action_input.split("\n")[0].strip() + + # Remove quotes if present + action_input = action_input.strip("\"'") + + # Try to parse JSON parameters + try: + import json + + parameters = json.loads(action_input) + except (json.JSONDecodeError, ValueError): + # If not valid JSON, treat as simple string parameter + parameters = {"command": action_input} + + tool_calls.append({"tool": tool_name, "parameters": parameters}) + + return tool_calls + + def _execute_tool(self, tool_name: str, **parameters: Any) -> str: + """Execute a tool and return the result.""" + try: + # Get tool class from registry + tool_class = self.tool_registry._tools.get(tool_name) + if not tool_class: + return f"Tool '{tool_name}' not found" + + # Create tool instance with parameters + tool_instance = tool_class(**parameters) + + # Execute the tool with context + result = tool_instance.execute(self.context) + + if result.data and "stdout" in result.data: + # Format output to show all information regardless of success/failure + output_parts = [] + + # Add stdout if present + if result.data["stdout"]: + output_parts.append(f"STDOUT:\n{result.data['stdout']}") + + # Add stderr if present + if result.data.get("stderr"): + output_parts.append(f"STDERR:\n{result.data['stderr']}") + + # Add return code + returncode = result.data.get("returncode", 0) + output_parts.append(f"Return code: {returncode}") + + # Add error message if command failed + if not result.success and result.error: + output_parts.append(f"Error: {result.error}") + + return "\n\n".join(output_parts) if output_parts else "Command executed successfully" + + # Handle other tool types + if result.success: + return str(result.data) if result.data else "Command executed successfully" + else: + return f"Tool execution failed: {result.error}" + except Exception as e: + return f"Error executing tool '{tool_name}': {e!s}" + + def _emit_react_steps(self, message: str, on_step: Optional[Callable[[str, str], None]]) -> None: + """Emit ReAct steps for debug mode.""" + if not on_step: + return + + # Parse ReAct components with simpler, more reliable regex patterns + thought_pattern = r"Thought:\s*(.*?)(?=\n(?:Action:|Final Answer:)|$)" + action_pattern = r"Action:\s*(.*?)(?=\n(?:Action Input:|Final Answer:)|$)" + action_input_pattern = r"Action Input:\s*(.*?)(?=\n(?:Observation:|Final Answer:)|$)" + final_answer_pattern = r"Final Answer:\s*(.*?)(?=\n|$)" + + thought_match = re.search(thought_pattern, message, re.IGNORECASE | re.MULTILINE | re.DOTALL) + action_match = re.search(action_pattern, message, re.IGNORECASE | re.MULTILINE | re.DOTALL) + action_input_match = re.search(action_input_pattern, message, re.IGNORECASE | re.MULTILINE | re.DOTALL) + final_answer_match = re.search(final_answer_pattern, message, re.IGNORECASE | re.MULTILINE | re.DOTALL) + + if thought_match: + thought_content = thought_match.group(1).strip() + # Clean up thought content - remove any trailing action/input + if "Action:" in thought_content: + thought_content = thought_content.split("Action:")[0].strip() + on_step("taao_thought", thought_content) + + if action_match: + action_content = action_match.group(1).strip() + # Clean up action content - remove any trailing input + if "Action Input:" in action_content: + action_content = action_content.split("Action Input:")[0].strip() + on_step("taao_action", action_content) + + if action_input_match: + action_input_content = action_input_match.group(1).strip() + # Clean up action input content - remove any trailing observation + if "Observation:" in action_input_content: + action_input_content = action_input_content.split("Observation:")[0].strip() + on_step("taao_action_input", action_input_content) + + if final_answer_match: + final_answer_content = final_answer_match.group(1).strip() + on_step("assistant", final_answer_content) + else: + # If no final answer but there's a thought, use the thought as the final answer + # This handles cases where the AI thinks about the result but doesn't provide explicit Final Answer + if thought_match and not action_match: + thought_content = thought_match.group(1).strip() + # Extract the actual tool output from the conversation history if available + # For now, just use the thought content as the final answer + on_step("assistant", thought_content) + else: + # If no final answer and no relevant thought, use the whole response + on_step("assistant", message) + + def _extract_final_answer(self, message: str) -> str: + """Extract the Final Answer from a ReAct message.""" + final_answer_match = re.search(r"Final Answer:\s*(.*)", message, re.IGNORECASE | re.MULTILINE | re.DOTALL) + if final_answer_match: + return final_answer_match.group(1).strip() + return message.strip() + + def reset_conversation(self) -> None: + """Reset the conversation history.""" + self.conversation_history = [] + + def add_tool(self, tool: Any) -> None: + """Add a tool to the registry. + + Args: + tool: Tool to add + """ + self.tool_registry.register_tool(tool) diff --git a/src/bub/core/context.py b/src/bub/core/context.py new file mode 100644 index 00000000..8b3e04a3 --- /dev/null +++ b/src/bub/core/context.py @@ -0,0 +1,120 @@ +"""Context for the agent package.""" + +from pathlib import Path +from typing import Any, Optional + +from openai.types.chat import ChatCompletionMessageParam + +from .prompt import DEFAULT_SYSTEM_PROMPT + + +class AgentContext: + """Agent environment context: workspace, config, tool registry, etc.""" + + def __init__( + self, + provider: str, + model_name: str, + api_key: str, + api_base: Optional[str] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None, + workspace_path: Optional[Path] = None, + ) -> None: + """Initialize the agent context. + + Args: + provider: LLM provider (e.g., 'openai', 'anthropic') + model_name: Model name (e.g., 'gpt-4', 'claude-3') + api_key: API key for the provider + api_base: Optional API base URL + max_tokens: Maximum tokens for responses + system_prompt: System prompt for the agent + workspace_path: Path to workspace + """ + self.provider = provider + self.model_name = model_name + self.api_key = api_key + self.api_base = api_base + self.max_tokens = max_tokens + self.system_prompt = DEFAULT_SYSTEM_PROMPT + "\n" + (system_prompt or "") + self.workspace_path = workspace_path or Path.cwd() + self.tool_registry: Optional[Any] = None # Will be set by Agent + self._conversation_history: list[ChatCompletionMessageParam] = [] + + def get_system_prompt(self) -> str: + """Get the system prompt or default.""" + if self.system_prompt: + return self.system_prompt + return DEFAULT_SYSTEM_PROMPT + + def build_context_message(self) -> str: + """Build a clean context message with essential information.""" + if not self.tool_registry: + return f"[Environment Context]\nWorkspace: {self.workspace_path}\nNo tools available" + + tool_schemas = self.tool_registry.get_tool_schemas() + available_tools = list(tool_schemas.keys()) + tool_details = self.tool_registry._format_schemas_for_context() + + msg = [ + "[Environment Context]", + f"Workspace: {self.workspace_path}", + f"Available tools ({len(available_tools)}): {', '.join(available_tools)}", + "", + "[Tool Definitions]", + tool_details, + "", + "[Usage Instructions]", + "To use a tool, provide the tool name and parameters in JSON format.", + 'Example: {"command": "ls -la"} for run_command tool', + ] + return "\n".join(msg) + + def reset(self) -> None: + """Reset the context state.""" + self.reset_conversation() + + def compress(self, max_messages: int = 10) -> None: + """Compress the conversation history.""" + self.compress_conversation(max_messages) + + def set_conversation_history(self, history: list[ChatCompletionMessageParam]) -> None: + """Set the conversation history. + + Args: + history: List of conversation messages + """ + self._conversation_history = history + + def get_conversation_history(self) -> list[ChatCompletionMessageParam]: + """Get the conversation history. + + Returns: + List of conversation messages + """ + return self._conversation_history + + def add_to_conversation_history(self, message: ChatCompletionMessageParam) -> None: + """Add a message to the conversation history. + + Args: + message: Message to add + """ + self._conversation_history.append(message) + + def reset_conversation(self) -> None: + """Reset the conversation history.""" + self._conversation_history = [] + + def compress_conversation(self, max_messages: int = 10) -> None: + """Compress conversation history to keep only recent messages. + + Args: + max_messages: Maximum number of messages to keep + """ + if len(self._conversation_history) > max_messages: + # Keep recent messages, prioritize keeping system message if any + + recent_messages = self._conversation_history[-max_messages:] + self._conversation_history = recent_messages diff --git a/src/bub/core/prompt.py b/src/bub/core/prompt.py new file mode 100644 index 00000000..df5a19aa --- /dev/null +++ b/src/bub/core/prompt.py @@ -0,0 +1,157 @@ +"""Default system prompt for Bub agent.""" + +DEFAULT_SYSTEM_PROMPT = """You are Bub, an autonomous software engineering agent that helps users with coding tasks. + +# Core Principles + +You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. + +Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough. + +You MUST iterate and keep going until the problem is solved. + +You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me. + +Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem. + +**IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames, directory structure, and existing codebase patterns.** + +When the user provides URLs or when you need to research external information, use the fetch tool to gather that information. If you find relevant links in the fetched content, follow them to gather comprehensive information. + +When working with third-party packages, libraries, or frameworks that you're unfamiliar with or need to verify usage patterns for, you can use the Sourcegraph tool to search for code examples across public repositories. This can help you understand best practices and common implementation patterns. + +If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is. + +Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking approach if needed. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided. + +You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. + +You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. + +You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input. + +# Proactiveness and Balance + +You should strive to strike a balance between: + +1. Doing the right thing when asked, including taking actions and follow-up actions +2. Not surprising the user with actions you take without asking +3. Being thorough and autonomous while staying focused on the user's actual request + +For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. However, when they ask you to solve a problem or implement something, be proactive in completing the entire task. + +# Workflow + +1. **Understand the Context**: Think about what the code you're editing is supposed to do based on filenames, directory structure, and existing patterns. +2. **Deep Problem Understanding**: Carefully read the issue and think critically about what is required. +3. **Codebase Investigation**: Explore relevant files, search for key functions, and gather context. +4. **Plan Development**: Develop a clear, step-by-step plan with a todo list. +5. **Incremental Implementation**: Make small, testable code changes. +6. **Debug and Test**: Debug as needed and test frequently. +7. **Iterate**: Continue until the root cause is fixed and all tests pass. +8. **Comprehensive Validation**: Reflect and validate thoroughly after tests pass. + +# Memory + +If the current working directory contains a file called BUB.md, it will be automatically added to your context. This file serves multiple purposes: + +1. Storing frequently used commands (build, test, lint, etc.) so you can use them without searching each time +2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.) +3. Maintaining useful information about the codebase structure and organization + +When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to BUB.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to BUB.md so you can remember it for next time. + +# How to Create a Todo List + +Use the following format to create a todo list: + +```markdown +- [ ] Step 1: Description of the first step +- [ ] Step 2: Description of the second step +- [ ] Step 3: Description of the third step +``` + +Do not ever use HTML tags or any other formatting for the todo list, as it will not be rendered correctly. Always use the markdown format shown above. Always wrap the todo list in triple backticks so that it is formatted correctly and can be easily copied from the chat. + +Always show the completed todo list to the user as the last item in your message, so that they can see that you have addressed all of the steps. + +# Communication Guidelines + +Always communicate clearly and concisely in a casual, friendly yet professional tone. + +- Respond with clear, direct answers. Use bullet points and code blocks for structure. +- Avoid unnecessary explanations, repetition, and filler. +- Always write code directly to the correct files. +- Do not display code to the user unless they specifically ask for it. +- Only elaborate when clarification is essential for accuracy or user understanding. + +# Tone and Style + +You should be concise, direct, and to the point. When you run a non-trivial command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing. + +Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. + +If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. + +IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. + +IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. + +VERY IMPORTANT: NEVER use emojis in your responses. + +# Following Conventions + +When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns. + +- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the pyproject.toml or requirements files. +- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions. +- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic. +- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository. + +# Code Style + +- IMPORTANT: DO NOT ADD **_ANY_** COMMENTS unless asked +- Follow the project's existing code style and conventions +- Use type hints for all functions and methods +- Follow PEP 8 guidelines for Python code + +# Task Execution + +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: + +1. Use the available search tools to understand the codebase and the user's query. +2. Implement the solution using all tools available to you +3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. +4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. ruff, mypy, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to BUB.md so that you will know to run it next time. + +NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. + +# Reading Files and Folders + +**Always check if you have already read a file, folder, or workspace structure before reading it again.** + +- If you have already read the content and it has not changed, do NOT re-read it. +- Only re-read files or folders if: + - You suspect the content has changed since your last read. + - You have made edits to the file or folder. + - You encounter an error that suggests the context may be stale or incomplete. +- Use your internal memory and previous context to avoid redundant reads. +- This will save time, reduce unnecessary operations, and make your workflow more efficient. + +# Error Handling and Recovery + +- When you encounter errors, don't give up - analyze the error carefully and try alternative approaches. +- If a tool fails, try a different tool or approach to accomplish the same goal. +- When debugging, be systematic: isolate the problem, test hypotheses, and iterate until resolved. +- Always validate your solutions work correctly before considering the task complete. + +# Final Validation + +Before completing any task: + +1. Ensure all todo items are checked off +2. Run all relevant tests +3. Run linting and type checking if available +4. Verify the original problem is solved +5. Test edge cases and boundary conditions +6. Confirm no regressions were introduced""" diff --git a/src/bub/core/tools/__init__.py b/src/bub/core/tools/__init__.py new file mode 100644 index 00000000..af1db256 --- /dev/null +++ b/src/bub/core/tools/__init__.py @@ -0,0 +1,159 @@ +"""Tools package for Bub.""" + +from pathlib import Path +from typing import Any + +from .base import Tool, ToolExecutor, ToolResult +from .commands import RunCommandTool +from .file_ops import FileEditTool, FileReadTool, FileWriteTool + + +class ToolRegistry: + """Simple tool registry for the agent.""" + + def __init__(self, workspace_path: Path) -> None: + """Initialize the tool registry. + + Args: + workspace_path: Path to the workspace + """ + self.workspace_path = workspace_path + self._tools: dict[str, type[Tool]] = {} + self._tool_instances: dict[str, Any] = {} + + # Auto-register available tools + self._register_default_tools() + + def _register_default_tools(self) -> None: + """Register default tools.""" + default_tools = [ + RunCommandTool, + FileReadTool, + FileWriteTool, + FileEditTool, + ] + + for tool_class in default_tools: + self.register_tool(tool_class) # type: ignore[arg-type] + + def register_tool(self, tool: Tool | type[Tool]) -> None: + """Register a tool. + + Args: + tool: Tool class or instance to register + """ + if isinstance(tool, type): + # Register tool class + tool_info = tool.get_tool_info() + self._tools[tool_info["name"]] = tool + + # Create a minimal instance for schema access + # Use the class itself for schema generation instead of creating an instance + self._tool_instances[tool_info["name"]] = tool + else: + # Register tool instance + self._tools[tool.name] = type(tool) + self._tool_instances[tool.name] = tool + + def list_tools(self) -> list[str]: + """List all registered tool names. + + Returns: + List of tool names + """ + return list(self._tools.keys()) + + def get_tool_schemas(self) -> dict[str, Any]: + """Get tool schemas. + + Returns: + Dictionary of tool schemas + """ + schemas = {} + for name, tool in self._tool_instances.items(): + if hasattr(tool, "model_json_schema"): + schemas[name] = tool.model_json_schema() + elif hasattr(tool, "schema"): + schemas[name] = tool.schema + else: + schemas[name] = {} + return schemas + + def _format_schemas_for_context(self) -> str: + """Format schemas for context. + + Returns: + Formatted schemas string + """ + schemas = [] + for name, tool in self._tool_instances.items(): + # Get detailed tool information + tool_info = tool.get_tool_info() if hasattr(tool, "get_tool_info") else {} + description = tool_info.get("description", "No description") + + # Get schema for parameters + schema = tool.model_json_schema() if hasattr(tool, "model_json_schema") else {} + properties = schema.get("properties", {}) + + # Format parameters + param_list = [] + for param_name, param_info in properties.items(): + # Skip internal tool fields + if param_name in ["name", "display_name", "description"]: + continue + + param_type = param_info.get("type", "any") + param_desc = param_info.get("description", "") + required = param_name in schema.get("required", []) + param_str = f"{param_name} ({param_type})" + if param_desc: + param_str += f": {param_desc}" + if required: + param_str += " [required]" + param_list.append(param_str) + + # Build tool description + tool_desc = f"{name}: {description}" + if param_list: + tool_desc += f" | Parameters: {', '.join(param_list)}" + + schemas.append(tool_desc) + + return "\n".join(schemas) + + def get_tool(self, tool_name: str) -> Any: + """Get a tool class by name. + + Args: + tool_name: Name of the tool to get + + Returns: + Tool class or None if not found + """ + return self._tools.get(tool_name) + + def get_tool_schema(self, tool_name: str) -> Any: + """Get the JSON schema for a tool. + + Args: + tool_name: Name of the tool to get schema for + + Returns: + Tool schema or None if not found + """ + tool_instance = self._tool_instances.get(tool_name) + if tool_instance and hasattr(tool_instance, "get_schema"): + return tool_instance.get_schema() + return None + + +__all__ = [ + "FileEditTool", + "FileReadTool", + "FileWriteTool", + "RunCommandTool", + "Tool", + "ToolExecutor", + "ToolRegistry", + "ToolResult", +] diff --git a/src/bub/agent/tools.py b/src/bub/core/tools/base.py similarity index 70% rename from src/bub/agent/tools.py rename to src/bub/core/tools/base.py index 24641a65..81c2cfbc 100644 --- a/src/bub/agent/tools.py +++ b/src/bub/core/tools/base.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, ValidationError -from .context import Context +from ...core.context import AgentContext class ToolResult(BaseModel): @@ -75,7 +75,7 @@ def get_description(self, params: dict[str, Any]) -> str: return f"Executing {self.display_name} with parameters: {json.dumps(params, indent=2)}" @abstractmethod - def execute(self, context: Context) -> ToolResult: + def execute(self, context: AgentContext) -> ToolResult: """Execute the tool with the given context.""" pass @@ -83,7 +83,7 @@ def execute(self, context: Context) -> ToolResult: class ToolExecutor: """Executes tools based on agent requests.""" - def __init__(self, context: Context) -> None: + def __init__(self, context: AgentContext) -> None: self.context = context self.tool_registry: Optional[Any] = getattr(context, "tool_registry", None) @@ -144,63 +144,3 @@ def execute_tool_calls(self, tool_calls: list[dict[str, Any]]) -> str: # ReAct Observation format results.append(f"Observation: {result.format_result()}") return "\n".join(results) if results else "No tools executed." - - -class ToolRegistry: - """Registry for available tools.""" - - def __init__(self) -> None: - self.tools: dict[str, type[Tool]] = {} - - def register_tool(self, tool_class: type[Tool]) -> None: - """Register a tool class.""" - # Get tool info from class method - tool_info = tool_class.get_tool_info() - self.tools[tool_info["name"]] = tool_class - - def register_default_tools(self) -> None: - """Register the default set of tools.""" - try: - from ..tools.file_edit import FileEditTool - from ..tools.file_read import FileReadTool - from ..tools.file_write import FileWriteTool - from ..tools.run_command import RunCommandTool - - self.register_tool(RunCommandTool) - self.register_tool(FileReadTool) - self.register_tool(FileWriteTool) - self.register_tool(FileEditTool) - except ImportError as e: - print(f"Warning: Could not load some tools: {e}") - - def get_tool(self, tool_name: str) -> Optional[type[Tool]]: - """Get a tool class by name.""" - return self.tools.get(tool_name) - - def list_tools(self) -> list[str]: - """List all available tool names.""" - return list(self.tools.keys()) - - def get_tool_schemas(self) -> dict[str, Any]: - """Get all tool schemas.""" - schemas = {} - for name, tool_class in self.tools.items(): - tool_info = tool_class.get_tool_info() - # Get the schema directly from the class without creating an instance - schema = tool_class.model_json_schema() - schemas[name] = {"name": tool_info["name"], "description": tool_info["description"], "parameters": schema} - return schemas - - def get_tool_schema(self, tool_name: str) -> Optional[dict[str, Any]]: - """Get the JSON schema for a tool.""" - tool_class = self.get_tool(tool_name) - if tool_class: - tool_info = tool_class.get_tool_info() - schema = tool_class.model_json_schema() - return {"name": tool_info["name"], "description": tool_info["description"], "parameters": schema} - return None - - def _format_schemas_for_context(self) -> str: - """Format tool schemas for context message.""" - schemas = self.get_tool_schemas() - return json.dumps(schemas, indent=2) diff --git a/src/bub/core/tools/commands.py b/src/bub/core/tools/commands.py new file mode 100644 index 00000000..2ebbc75c --- /dev/null +++ b/src/bub/core/tools/commands.py @@ -0,0 +1,341 @@ +"""Command execution tool for Bub.""" + +import os +import shlex +import subprocess +import threading +from typing import Any, Callable, ClassVar, Optional + +import logfire +from pydantic import Field + +from ...core.context import AgentContext +from .base import Tool, ToolResult + + +class CommandBlocker: + """Flexible command blocking system inspired by crush's design.""" + + def __init__(self) -> None: + self.block_funcs: list[Callable[[list[str]], bool]] = [] + + def add_blocker(self, block_func: Callable[[list[str]], bool]) -> None: + """Add a blocking function.""" + self.block_funcs.append(block_func) + + def should_block(self, args: list[str]) -> Optional[str]: + """Check if command should be blocked.""" + for block_func in self.block_funcs: + if block_func(args): + return f"Command blocked: {' '.join(args)}" + return None + + @staticmethod + def commands_blocker(banned_commands: list[str]) -> Callable[[list[str]], bool]: + """Create a blocker for exact command matches.""" + banned_set = set(banned_commands) + + def blocker(args: list[str]) -> bool: + if not args: + return False + return args[0] in banned_set + + return blocker + + @staticmethod + def arguments_blocker(blocked_subcommands: list[list[str]]) -> Callable[[list[str]], bool]: + """Create a blocker for specific subcommands.""" + + def blocker(args: list[str]) -> bool: + for blocked in blocked_subcommands: + if len(args) >= len(blocked): + match = True + for i, part in enumerate(blocked): + if args[i] != part: + match = False + break + if match: + return True + return False + + return blocker + + +class RunCommandTool(Tool): + """Execute a terminal command in the workspace. + + This tool runs shell commands with comprehensive security measures including + command validation, working directory restrictions, and timeout handling. + It automatically blocks dangerous commands and patterns to ensure safety. + + Usage example: + Action: run_command + Action Input: {"command": "ls -la", "cwd": "src", "timeout": 30} + + Parameters: + command: The shell command to execute (e.g., 'ls', 'cat file.txt'). + cwd: Optional. The working directory to run the command in. Defaults to workspace root. + timeout: Optional. The timeout in seconds for the command to run. Defaults to 30 seconds. + + Security Features: + - Blocks dangerous commands (rm, sudo, systemctl, etc.) + - Blocks dangerous patterns (;, &&, ||, |, etc.) + - Validates working directory is within workspace + - Sanitizes environment variables + - Implements command timeout with graceful termination + """ + + name: str = Field(default="run_command", description="The internal name of the tool") + display_name: str = Field(default="Run Command", description="The user-friendly display name") + description: str = Field( + default="Execute a terminal command in the workspace with security measures", + description="Description of what the tool does", + ) + + command: str = Field(..., description="The shell command to execute, e.g., 'ls', 'cat file.txt'.") + cwd: Optional[str] = Field( + default=None, description="Optional. The working directory to run the command in. Defaults to workspace root." + ) + timeout: int = Field( + default=30, description="The timeout in seconds for the command to run. Defaults to 30 seconds." + ) + + # Dangerous commands that should be blocked + DANGEROUS_COMMANDS: ClassVar[set[str]] = { + # File system operations + "rm", + "del", + "format", + "mkfs", + "dd", + "shred", + "wipe", + "fdisk", + # Permission operations + "chmod", + "chown", + "sudo", + "su", + # User management + "passwd", + "useradd", + "userdel", + "usermod", + # System services + "systemctl", + "service", + "init", + "reboot", + "shutdown", + "halt", + # Process management + "killall", + "pkill", + "kill", # Network operations + "iptables", + "firewall-cmd", + "ufw", + # Package management + "apt", + "yum", + "dnf", + "pacman", + "brew", + # Shell operations + "eval", + "exec", + "source", + ".", + } + + # Dangerous command patterns + DANGEROUS_PATTERNS: ClassVar[set[str]] = {";", "&&", "||", "|", ">", "<", "`", "$(", "eval", "exec", "source"} + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + self._command_blocker = CommandBlocker() + self._setup_blockers() + + def _setup_blockers(self) -> None: + """Setup command blockers.""" + # Block dangerous commands + self._command_blocker.add_blocker(CommandBlocker.commands_blocker(list(self.DANGEROUS_COMMANDS))) + + # Block dangerous subcommands + dangerous_subcommands = [ + ["git", "reset", "--hard"], + ["git", "clean", "-fd"], + ["docker", "rm", "-f"], + ["docker", "rmi", "-f"], + ] + self._command_blocker.add_blocker(CommandBlocker.arguments_blocker(dangerous_subcommands)) + + @classmethod + def get_tool_info(cls) -> dict[str, Any]: + """Get tool metadata.""" + return { + "name": "run_command", + "display_name": "Run Command", + "description": cls.__doc__, + } + + def _validate_command(self) -> Optional[str]: + """Validate command for security.""" + # Check for empty command + if not self.command.strip(): + return "Empty command" + + # Check for dangerous patterns + for pattern in self.DANGEROUS_PATTERNS: + if pattern in self.command: + return f"Potentially dangerous command pattern: {pattern}" + + # Parse command and check with blockers + try: + cmd_parts = shlex.split(self.command) + block_reason = self._command_blocker.should_block(cmd_parts) + if block_reason: + return block_reason + except ValueError as e: + return f"Invalid command syntax: {e}" + + return None + + def _validate_working_directory(self, workspace_path: str) -> Optional[str]: + """Validate working directory is within workspace.""" + if not self.cwd: + return None + + try: + cwd_abs = os.path.abspath(self.cwd) + workspace_abs = os.path.abspath(workspace_path) + + # Check if cwd is within workspace + if not cwd_abs.startswith(workspace_abs): + return f"Working directory {self.cwd} is outside workspace" + + # Check if directory exists + if not os.path.exists(cwd_abs): + return f"Working directory {self.cwd} does not exist" + + if not os.path.isdir(cwd_abs): + return f"Working directory {self.cwd} is not a directory" + + except Exception as e: + return f"Invalid working directory: {e}" + + return None + + def execute(self, context: AgentContext) -> ToolResult: + """Execute the command.""" + try: + # Validate command first + validation_error = self._validate_command() + if validation_error: + return ToolResult(success=False, data=None, error=validation_error) + + # Validate working directory + working_dir = self.cwd + if not working_dir: + working_dir = str(context.workspace_path) + + dir_validation_error = self._validate_working_directory(str(context.workspace_path)) + if dir_validation_error: + return ToolResult(success=False, data=None, error=dir_validation_error) + + # Parse command + try: + cmd_parts = shlex.split(self.command) + except ValueError as e: + return ToolResult(success=False, data=None, error=f"Invalid command syntax: {e}") + + # Execute command with timeout + result = self._run_command_with_timeout(cmd_parts, working_dir) + + return ToolResult( + success=(result["returncode"] == 0), + data={ + "stdout": result["stdout"], + "stderr": result["stderr"], + "returncode": result["returncode"], + "command": self.command, + "cwd": working_dir, + }, + error=None if result["returncode"] == 0 else f"Command failed with return code {result['returncode']}", + ) + except Exception as e: + import traceback + + tb = traceback.format_exc() + return ToolResult(success=False, data=None, error=f"Error executing command: {e!s}\nTraceback:\n{tb}") + + def _run_command_with_timeout(self, cmd_parts: list[str], working_dir: str) -> dict[str, Any]: + """Run command with timeout handling.""" + result = {"stdout": "", "stderr": "", "returncode": 1} + + def _handle_timeout() -> None: + """Handle command timeout by killing the process.""" + process.terminate() + thread.join(timeout=5) # Give it 5 seconds to terminate gracefully + if thread.is_alive(): + process.kill() # Force kill if still alive + + def _raise_timeout() -> None: + """Raise timeout exception.""" + raise subprocess.TimeoutExpired(cmd_parts, self.timeout) + + try: + # Security: Only allow trusted commands that have been validated + process = subprocess.Popen( # noqa: S603 + cmd_parts, + cwd=working_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=self._get_environment(), + ) + + # Use threading to handle timeout + def target() -> None: + try: + stdout, stderr = process.communicate() + result["stdout"] = stdout + result["stderr"] = stderr + result["returncode"] = process.returncode + except Exception: + logfire.exception("Error executing command") + + thread = threading.Thread(target=target) + thread.daemon = True + thread.start() + thread.join(timeout=self.timeout) + + if thread.is_alive(): + # Command timed out, kill the process + _handle_timeout() + # Raise timeout exception + _raise_timeout() + + except subprocess.TimeoutExpired: + result["stderr"] = f"Command timed out after {self.timeout} seconds" + result["returncode"] = -1 + except Exception as e: + result["stderr"] = str(e) + result["returncode"] = -1 + + return result + + def _get_environment(self) -> dict[str, str]: + """Get environment variables for command execution.""" + env = os.environ.copy() + + # Add some safety environment variables + env["PATH"] = env.get("PATH", "") + env["HOME"] = env.get("HOME", "") + + # Remove potentially dangerous environment variables + dangerous_vars = ["SUDO_ASKPASS", "SSH_AUTH_SOCK", "SSH_AGENT_PID"] + for var in dangerous_vars: + env.pop(var, None) + + return env diff --git a/src/bub/core/tools/file_ops.py b/src/bub/core/tools/file_ops.py new file mode 100644 index 00000000..9d30d14c --- /dev/null +++ b/src/bub/core/tools/file_ops.py @@ -0,0 +1,485 @@ +"""File operations tools for Bub.""" + +from pathlib import Path +from typing import Any, Literal, Optional + +import logfire +from pydantic import Field + +from ...core.context import AgentContext +from .base import Tool, ToolResult + +# Common ignore patterns inspired by crush's CommonIgnorePatterns +COMMON_IGNORE_PATTERNS = { + # Version control + ".git", + ".svn", + ".hg", + ".bzr", + # IDE and editor files + ".vscode", + ".idea", + "*.swp", + "*.swo", + "*~", + ".DS_Store", + "Thumbs.db", + # Build artifacts and dependencies + "node_modules", + "target", + "build", + "dist", + "out", + "bin", + "obj", + "*.o", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + # Logs and temporary files + "*.log", + "*.tmp", + "*.temp", + ".cache", + ".tmp", + # Language-specific + "__pycache__", + "*.pyc", + "*.pyo", + ".pytest_cache", + "vendor", + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + # OS generated files + ".Trash", + ".Spotlight-V100", + ".fseventsd", + # Bub specific + ".bub", +} + + +def _check_common_patterns(path: Path) -> bool: + """Check if path matches common ignore patterns.""" + for pattern in COMMON_IGNORE_PATTERNS: + if pattern.startswith("*"): + # Wildcard pattern + if path.name.endswith(pattern[1:]): + return True + else: + # Exact match + if path.name == pattern or any(part == pattern for part in path.parts): + return True + return False + + +def _check_gitignore_patterns(rel_path: Path, workspace_path: Path) -> bool: + """Check if path matches gitignore patterns.""" + gitignore_path = workspace_path / ".gitignore" + if not gitignore_path.exists(): + return False + + try: + with open(gitignore_path, encoding="utf-8") as f: + gitignore_patterns = [line.strip() for line in f if line.strip() and not line.startswith("#")] + + for pattern in gitignore_patterns: + if pattern.startswith("/"): + # Absolute pattern from workspace root + if str(rel_path) == pattern[1:] or str(rel_path).startswith(pattern[1:] + "/"): + return True + elif pattern.endswith("/"): + # Directory pattern + if str(rel_path).startswith(pattern[:-1]): + return True + else: + # File pattern + if str(rel_path) == pattern or str(rel_path).endswith("/" + pattern): + return True + except Exception: + logfire.exception("Error checking gitignore patterns") + + return False + + +def should_ignore_path(path: Path, workspace_path: Path) -> bool: + """Check if a path should be ignored based on common patterns and gitignore.""" + try: + rel_path = path.relative_to(workspace_path) + except ValueError: + # Path is outside workspace, check if it's hidden + return path.name.startswith(".") + + # Check for hidden files/directories + if path.name.startswith("."): + return True + + # Check common ignore patterns + if _check_common_patterns(path): + return True + + # Check gitignore patterns + return _check_gitignore_patterns(rel_path, workspace_path) + + +def is_binary_file(file_path: Path, sample_size: int = 1024) -> bool: + """Detect if a file is binary by checking for null bytes in the first sample_size bytes.""" + try: + with open(file_path, "rb") as f: + sample = f.read(sample_size) + return b"\x00" in sample + except Exception: + return False + + +def validate_file_path(file_path: Path, workspace_path: Path) -> tuple[bool, Optional[str]]: + """Validate file path for security and accessibility.""" + try: + # Resolve to absolute path + abs_path = file_path.resolve() + workspace_abs = workspace_path.resolve() + + # Check if path is within workspace + try: + abs_path.relative_to(workspace_abs) + except ValueError: + return False, "Path is outside workspace" + + # Check for path traversal attempts + if ".." in str(file_path): + return False, "Path traversal not allowed" + + except Exception as e: + return False, f"Invalid path: {e}" + + return True, None + + +class FileReadTool(Tool): + """Read the contents of a file in the workspace. + + This tool reads text files and returns their content. It automatically filters out + binary files, hidden files, and files that should be ignored based on .gitignore + and common ignore patterns. + + Usage example: + Action: read_file + Action Input: {"path": "config.txt"} + + Parameters: + path: The relative or absolute path to the file to read. + """ + + name: str = Field(default="read_file", description="The internal name of the tool") + display_name: str = Field(default="Read File", description="The user-friendly display name") + description: str = Field(default=__doc__, description="Description of what the tool does") + + path: str = Field(..., description="The relative or absolute path to the file to read.") + + @classmethod + def get_tool_info(cls) -> dict[str, Any]: + """Get tool metadata.""" + return { + "name": "read_file", + "display_name": "Read File", + "description": cls.__doc__, + } + + def execute(self, context: AgentContext) -> ToolResult: + """Execute the file read operation.""" + try: + from .utils import sanitize_path + + file_path = Path(self.path) + if not file_path.is_absolute(): + file_path = context.workspace_path / file_path + + # Validate path + is_valid, error_msg = validate_file_path(file_path, context.workspace_path) + if not is_valid: + return ToolResult(success=False, data=None, error=error_msg) + + if not file_path.exists(): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File not found: {safe_path}") + + if not file_path.is_file(): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"Path is not a file: {safe_path}") + + # Check if file should be ignored + if should_ignore_path(file_path, context.workspace_path): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File is ignored: {safe_path}") + + # Check if file is binary + if is_binary_file(file_path): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File is binary: {safe_path}") + + try: + content = file_path.read_text(encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=content, error=None) + except UnicodeDecodeError: + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File is not a text file: {safe_path}") + except Exception as e: + return ToolResult(success=False, data=None, error=f"Error reading file: {e!s}") + + +class FileWriteTool(Tool): + """Write content to a file in the workspace. + + This tool creates or overwrites files with the specified content. It automatically + creates parent directories if they don't exist and supports both overwrite and + append modes. + + Usage example: + Action: write_file + Action Input: {"path": "output.txt", "content": "Hello, world!", "mode": "overwrite"} + + Parameters: + path: The relative or absolute path to the file to write. + content: The content to write to the file. + mode: Write mode - "overwrite" (default) or "append". + """ + + name: str = Field(default="write_file", description="The internal name of the tool") + display_name: str = Field(default="Write File", description="The user-friendly display name") + description: str = Field(default=__doc__, description="Description of what the tool does") + + path: str = Field(..., description="The relative or absolute path to the file to write.") + content: str = Field(..., description="The content to write to the file.") + mode: str = Field(default="overwrite", description="Write mode: 'overwrite' or 'append'") + + @classmethod + def get_tool_info(cls) -> dict[str, Any]: + """Get tool metadata.""" + return { + "name": "write_file", + "display_name": "Write File", + "description": cls.__doc__, + } + + def execute(self, context: AgentContext) -> ToolResult: + """Execute the file write operation.""" + try: + from .utils import sanitize_path + + file_path = Path(self.path) + if not file_path.is_absolute(): + file_path = context.workspace_path / file_path + + # Validate path + is_valid, error_msg = validate_file_path(file_path, context.workspace_path) + if not is_valid: + return ToolResult(success=False, data=None, error=error_msg) + + # Ensure parent directory exists + file_path.parent.mkdir(parents=True, exist_ok=True) + + if self.mode == "append" and file_path.exists(): + # Append mode + existing_content = file_path.read_text(encoding="utf-8") + new_content = existing_content + self.content + file_path.write_text(new_content, encoding="utf-8") + else: + # Overwrite mode (default) + file_path.write_text(self.content, encoding="utf-8") + + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File written successfully: {safe_path}", error=None) + except Exception as e: + return ToolResult(success=False, data=None, error=f"Error writing file: {e!s}") + + +class FileEditTool(Tool): + """Edit file content with fine-grained operations. + + This tool provides precise editing capabilities for text files, including line-based + operations, text replacement, and content insertion. It automatically validates paths + and filters out binary or ignored files. + + Usage example: + Action: edit_file + Action Input: {"path": "config.txt", "operation": "replace_lines", "start_line": 1, "end_line": 3, "content": "new content"} + + Parameters: + path: The relative or absolute path to the file to edit. + operation: The type of edit operation. + content: The content to use in the operation. + start_line: Start line number (1-based, inclusive). For line-based ops. + end_line: End line number (1-based, inclusive). For line-based ops. + match_text: Text to match for replace_text operation. + replace_text: Replacement text for replace_text operation. + line_number: Line number for insert_after/insert_before (1-based). + + Supported operations: + - replace_lines: Replace lines in a given range (1-based, inclusive) + - replace_text: Replace all occurrences of match_text with replace_text + - insert_after: Insert content after a given line number + - insert_before: Insert content before a given line number + - delete_lines: Delete lines in a given range (1-based, inclusive) + - append: Append content to the end of the file + - prepend: Prepend content to the beginning of the file + """ + + name: str = Field(default="edit_file", description="The internal name of the tool") + display_name: str = Field(default="Edit File", description="The user-friendly display name") + description: str = Field(default=__doc__, description="Description of what the tool does") + + path: str = Field(..., description="The relative or absolute path to the file to edit.") + operation: Literal[ + "replace_lines", "replace_text", "insert_after", "insert_before", "delete_lines", "append", "prepend" + ] = Field(..., description="The type of edit operation.") + content: Optional[str] = Field(None, description="The content to use in the operation.") + start_line: Optional[int] = Field(None, description="Start line number (1-based, inclusive). For line-based ops.") + end_line: Optional[int] = Field(None, description="End line number (1-based, inclusive). For line-based ops.") + match_text: Optional[str] = Field(None, description="Text to match for replace_text operation.") + replace_text: Optional[str] = Field(None, description="Replacement text for replace_text operation.") + line_number: Optional[int] = Field(None, description="Line number for insert_after/insert_before (1-based).") + + @classmethod + def get_tool_info(cls) -> dict[str, Any]: + return { + "name": "edit_file", + "display_name": "Edit File", + "description": cls.__doc__, + } + + def execute(self, context: AgentContext) -> ToolResult: + try: + from .utils import sanitize_path + + file_path = Path(self.path) + if not file_path.is_absolute(): + file_path = context.workspace_path / file_path + + # Validate path + is_valid, error_msg = validate_file_path(file_path, context.workspace_path) + if not is_valid: + return ToolResult(success=False, data=None, error=error_msg) + + if not file_path.exists(): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File not found: {safe_path}") + + # Check if file should be ignored + if should_ignore_path(file_path, context.workspace_path): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File is ignored: {safe_path}") + + # Check if file is binary + if is_binary_file(file_path): + safe_path = sanitize_path(file_path) + return ToolResult(success=False, data=None, error=f"File is binary: {safe_path}") + + lines = file_path.read_text(encoding="utf-8").splitlines(keepends=True) + dispatch = { + "replace_lines": self._replace_lines, + "replace_text": self._replace_text, + "insert_after": self._insert_after, + "insert_before": self._insert_before, + "delete_lines": self._delete_lines, + "append": self._append, + "prepend": self._prepend, + } + func = dispatch.get(self.operation) + if func is None: + return ToolResult(success=False, data=None, error=f"Unknown operation: {self.operation}") + return func(lines, file_path) + except Exception as e: + return ToolResult(success=False, data=None, error=f"Error editing file: {e!s}") + + def _replace_lines(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.start_line is None or self.end_line is None or self.content is None: + return ToolResult( + success=False, data=None, error="start_line, end_line, and content are required for replace_lines." + ) + if not (1 <= self.start_line <= self.end_line <= len(lines)): + return ToolResult(success=False, data=None, error="Invalid line range.") + new_content_lines = self.content.splitlines(keepends=True) + new_lines = lines[: self.start_line - 1] + new_content_lines + lines[self.end_line :] + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _replace_text(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.match_text is None or self.replace_text is None: + return ToolResult( + success=False, data=None, error="match_text and replace_text are required for replace_text." + ) + file_text = "".join(lines) + new_text = file_text.replace(self.match_text, self.replace_text) + new_lines = new_text.splitlines(keepends=True) + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _insert_after(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.line_number is None or self.content is None: + return ToolResult(success=False, data=None, error="line_number and content are required for insert_after.") + if not (0 <= self.line_number <= len(lines)): + return ToolResult(success=False, data=None, error="Invalid line_number.") + new_content_lines = self.content.splitlines(keepends=True) + new_lines = lines[: self.line_number] + new_content_lines + lines[self.line_number :] + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _insert_before(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.line_number is None or self.content is None: + return ToolResult(success=False, data=None, error="line_number and content are required for insert_before.") + if not (1 <= self.line_number <= len(lines) + 1): + return ToolResult(success=False, data=None, error="Invalid line_number.") + new_content_lines = self.content.splitlines(keepends=True) + new_lines = lines[: self.line_number - 1] + new_content_lines + lines[self.line_number - 1 :] + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _delete_lines(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.start_line is None or self.end_line is None: + return ToolResult(success=False, data=None, error="start_line and end_line are required for delete_lines.") + if not (1 <= self.start_line <= self.end_line <= len(lines)): + return ToolResult(success=False, data=None, error="Invalid line range.") + new_lines = lines[: self.start_line - 1] + lines[self.end_line :] + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _append(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.content is None: + return ToolResult(success=False, data=None, error="content is required for append.") + new_content_lines = self.content.splitlines(keepends=True) + new_lines = lines + new_content_lines + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) + + def _prepend(self, lines: list[str], file_path: Path) -> ToolResult: + from .utils import sanitize_path + + if self.content is None: + return ToolResult(success=False, data=None, error="content is required for prepend.") + new_content_lines = self.content.splitlines(keepends=True) + new_lines = new_content_lines + lines + file_path.write_text("".join(new_lines), encoding="utf-8") + safe_path = sanitize_path(file_path) + return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) diff --git a/src/bub/core/tools/utils.py b/src/bub/core/tools/utils.py new file mode 100644 index 00000000..ece62021 --- /dev/null +++ b/src/bub/core/tools/utils.py @@ -0,0 +1,186 @@ +"""Utility functions for Bub tools.""" + +from pathlib import Path +from typing import Union + + +def sanitize_path(path: Union[str, Path]) -> str: + """Convert absolute path to relative path from home directory for privacy. + + Args: + path: The path to sanitize + + Returns: + A privacy-safe path representation + """ + path = Path(path).resolve() + home = Path.home() + + if path == home: + return "~" + elif path.is_relative_to(home): + return str("~" / path.relative_to(home)) + elif path == Path("/"): + return "/" + else: + # For other absolute paths, show relative to current working directory + try: + return str(path.relative_to(Path.cwd())) + except ValueError: + # If not relative to cwd, show just the name + return path.name or str(path) + + +def pretty_path(path: Union[str, Path]) -> str: + """Convert path to a pretty display format, replacing home directory with ~. + + Inspired by crush's PrettyPath function. + + Args: + path: The path to prettify + + Returns: + A pretty path representation + """ + path = Path(path).resolve() + home = Path.home() + + if path == home: + return "~" + elif path.is_relative_to(home): + return str("~" / path.relative_to(home)) + else: + return str(path) + + +def dir_trim(pwd: Union[str, Path], limit: int) -> str: + """Trim directory path for display purposes. + + Inspired by crush's DirTrim function. + + Args: + pwd: The path to trim + limit: Maximum number of directory levels to show + + Returns: + A trimmed path representation + """ + pwd = Path(pwd).resolve() + home = Path.home() + + # If path is in home directory, start with ~ + if pwd.is_relative_to(home): + pwd = Path("~") / pwd.relative_to(home) + + parts = pwd.parts + + if limit <= 0 or limit >= len(parts) - 1: + return str(pwd) + + # Keep the last 'limit' parts, replace others with ... + if len(parts) <= limit + 1: + return str(pwd) + + # For absolute paths, keep the root + if pwd.is_absolute(): + result_parts = [parts[0]] # Keep root + for _ in range(1, len(parts) - limit + 1): + result_parts.append("...") + result_parts.extend(parts[-(limit - 1) :]) + else: + # For relative paths + result_parts = ["..."] + result_parts.extend(parts[-(limit):]) + + return str(Path(*result_parts)) + + +def is_hidden_file(path: Union[str, Path]) -> bool: + """Check if a file or directory is hidden. + + Args: + path: The path to check + + Returns: + True if the file/directory is hidden + """ + path = Path(path) + return path.name.startswith(".") + + +def get_file_size_display(size_bytes: int) -> str: + """Convert file size in bytes to human-readable format. + + Args: + size_bytes: Size in bytes + + Returns: + Human-readable size string + """ + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size_float = float(size_bytes) + while size_float >= 1024 and i < len(size_names) - 1: + size_float = size_float / 1024.0 + i += 1 + + return f"{size_float:.1f} {size_names[i]}" + + +def normalize_path(path: Union[str, Path]) -> Path: + """Normalize a path, resolving any symlinks and making it absolute. + + Args: + path: The path to normalize + + Returns: + Normalized Path object + """ + return Path(path).resolve() + + +def is_within_directory(directory: Union[str, Path], path: Union[str, Path]) -> bool: + """Check if a path is within a directory. + + Args: + directory: The directory to check within + path: The path to check + + Returns: + True if the path is within the directory + """ + try: + directory = Path(directory).resolve() + path = Path(path).resolve() + path.relative_to(directory) + except ValueError: + return False + + return True + + +def safe_filename(filename: str) -> str: + """Convert a string to a safe filename. + + Args: + filename: The filename to sanitize + + Returns: + A safe filename + """ + # Remove or replace unsafe characters + unsafe_chars = '<>:"/\\|?*' + for char in unsafe_chars: + filename = filename.replace(char, "_") + + # Remove leading/trailing spaces and dots + filename = filename.strip(" .") + + # Ensure it's not empty + if not filename: + filename = "unnamed" + + return filename diff --git a/src/bub/events/__init__.py b/src/bub/events/__init__.py new file mode 100644 index 00000000..374736d5 --- /dev/null +++ b/src/bub/events/__init__.py @@ -0,0 +1,213 @@ +"""Events Framework - A standalone event system for Bub. + +This framework provides a clean, adapter-based event system that can work +with different backends (currently eventure, but extensible). + +Public API: + - EventSystem: Main facade for publishing/subscribing + - BaseEvent: Base class for all events + - register_event: Decorator for registering event schemas + - get_event_system: Get global event system instance + - DomainEventType: Base for domain event types + +Usage: + from bub.events import EventSystem, BaseEvent, register_event + + @register_event + class MyEvent(BaseEvent): + event_type = MyEventType.SOMETHING + data: str + + system = EventSystem() + system.publish(MyEvent(data="hello")) + + @system.subscribe(MyEventType.SOMETHING) + def handle_event(event): + print(f"Got: {event.data}") +""" + +from typing import Callable + +from eventure import Event, EventBus, EventLog, EventQuery + +from .adapters import EventBusAdapter, NullEventBusAdapter +from .bridges import ( + BaseDomain, + DomainEventBridge, + EventSystemDomainBridge, + LogfireDomainEventBridge, + NullDomainEventBridge, +) +from .exceptions import ( + BusNotFoundError, + EventAdapterError, + EventBusError, + EventError, + EventSubscriptionError, + EventTypeError, + EventValidationError, + SchemaNotFoundError, +) +from .models import BaseEvent +from .registry import get_event_schema, get_event_schema_or_raise, register_event +from .system import EventSystem, get_event_system, set_event_system +from .types import DomainEventType, EventHandler, EventType, Subscription + + +# Convenience functions using global event system +def publish( + event: BaseEvent | str, + data: dict[str, str | int | float | bool] | None = None, + *, + parent: Event | None = None, + bus: str = "default", +) -> Event: + """Publish an event using the global event system. + + Args: + event: Either a BaseEvent instance or event type string + data: Event data (only used if event is a string) + parent: Optional parent event for cascading + bus: Bus name to publish to + + Returns: + Eventure Event object + + """ + return get_event_system().publish(event, data, parent=parent, bus=bus) + + +def subscribe( + event_type: EventType, + handler: EventHandler | None = None, + *, + bus: str = "default", +) -> Subscription | Callable[[EventHandler], EventHandler]: + """Subscribe to an event type using the global event system. + + Args: + event_type: The event type to subscribe to + handler: Optional handler function (required for direct call) + bus: Bus name to subscribe to + + Returns: + Decorator function or subscription object + + """ + return get_event_system().subscribe(event_type, handler, bus=bus) + + +def subscribe_to_all( + handler: EventHandler | None = None, + *, + bus: str = "default", +) -> Subscription | Callable[[EventHandler], EventHandler]: + """Subscribe to all events using the global event system. + + Args: + handler: Optional handler function (required for direct call) + bus: Bus name to subscribe to + + Returns: + Decorator function or subscription object + + """ + return get_event_system().subscribe_to_all(handler, bus=bus) + + +def get_bus(name: str = "default") -> EventBus: + """Get a bus instance from the global event system. + + Args: + name: Bus name + + Returns: + Eventure EventBus object + + """ + return get_event_system().get_bus(name) + + +def get_log(name: str = "default") -> EventLog: + """Get a log instance from the global event system. + + Args: + name: Bus name + + Returns: + Eventure EventLog object + + """ + return get_event_system().get_log(name) + + +def get_query(name: str = "default") -> EventQuery: + """Get a query interface from the global event system. + + Args: + name: Bus name + + Returns: + Eventure EventQuery object + + """ + return get_event_system().get_query(name) + + +def create_bus(name: str) -> EventBus: + """Create a new bus in the global event system. + + Args: + name: Name of the bus to create + + Returns: + Eventure EventBus object + + """ + return get_event_system().create_bus(name) + + +# Public API exports +__all__ = [ # noqa: RUF022 + # Core classes + "EventSystem", + "BaseEvent", + "DomainEventType", + # Bridges + "BaseDomain", + "DomainEventBridge", + "EventSystemDomainBridge", + "LogfireDomainEventBridge", + "NullDomainEventBridge", + # Registration + "register_event", + "get_event_schema", + "get_event_schema_or_raise", + # Global system + "get_event_system", + "set_event_system", + # Types + "EventType", + "EventHandler", + "Subscription", + # Adapters + "EventBusAdapter", + "NullEventBusAdapter", + # Exceptions + "BusNotFoundError", + "SchemaNotFoundError", + "EventError", + "EventTypeError", + "EventBusError", + "EventSubscriptionError", + "EventValidationError", + "EventAdapterError", + # Convenience functions + "publish", + "subscribe", + "subscribe_to_all", + "get_bus", + "get_log", + "get_query", + "create_bus", +] diff --git a/src/bub/events/adapters.py b/src/bub/events/adapters.py new file mode 100644 index 00000000..9383f48c --- /dev/null +++ b/src/bub/events/adapters.py @@ -0,0 +1,164 @@ +"""Event bus adapters.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from eventure import Event, EventBus, EventLog, EventQuery + +from .models import BaseEvent +from .types import EventHandler, EventType, Subscription + + +class EventBusAdapter(ABC): + """Abstract base class for event bus adapters. + + This interface allows plugging different event bus implementations + behind a consistent API. + """ + + @abstractmethod + def publish( + self, + event: BaseEvent, + parent_event: Event | None = None, + bus_name: str | None = None, + ) -> Event: + """Publish an event to the bus. + + Args: + event: The event to publish + parent_event: Optional parent event for cascading + bus_name: Optional bus name to publish to + + Returns: + Eventure Event object + """ + + @abstractmethod + def subscribe( + self, + event_type: EventType, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """Subscribe to an event type. + + Args: + event_type: The event type to subscribe to + handler: The handler function + bus_name: Optional bus name to subscribe to + + Returns: + Eventure unsubscribe function + """ + + @abstractmethod + def subscribe_to_all( + self, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """Subscribe to all events. + + Args: + handler: The handler function + bus_name: Optional bus name to subscribe to + + Returns: + Eventure unsubscribe function + """ + + @abstractmethod + def get_bus(self, bus_name: str | None = None) -> EventBus: + """Get a bus instance. + + Args: + bus_name: Optional bus name + + Returns: + Eventure EventBus object + """ + + @abstractmethod + def get_log(self, bus_name: str | None = None) -> EventLog: + """Get a log instance for querying events. + + Args: + bus_name: Optional bus name + + Returns: + Eventure EventLog object + """ + + @abstractmethod + def create_bus(self, bus_name: str) -> EventBus: + """Create a new bus. + + Args: + bus_name: Name of the bus to create + + Returns: + Eventure EventBus object + """ + + @abstractmethod + def get_query(self, bus_name: str | None = None) -> EventQuery: + """Get a query interface for an event log. + + Args: + bus_name: Optional bus name + + Returns: + Eventure EventQuery object + """ + + +class NullEventBusAdapter(EventBusAdapter): + """No-op adapter for testing or when events are disabled. + + This adapter implements the interface but performs no operations, + making it ideal for testing or when event functionality is disabled. + """ + + def publish( + self, + event: BaseEvent, + parent_event: Event | None = None, + bus_name: str | None = None, + ) -> Event: + """No-op publish implementation.""" + raise NotImplementedError("NullEventBusAdapter does not support publishing") + + def subscribe( + self, + event_type: EventType, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """No-op subscribe implementation.""" + return lambda: None + + def subscribe_to_all( + self, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """No-op subscribe to all implementation.""" + return lambda: None + + def get_bus(self, bus_name: str | None = None) -> EventBus: + """Return None bus.""" + raise NotImplementedError("NullEventBusAdapter does not support bus operations") + + def get_log(self, bus_name: str | None = None) -> EventLog: + """Return None log.""" + raise NotImplementedError("NullEventBusAdapter does not support log operations") + + def create_bus(self, bus_name: str) -> EventBus: + """Return None bus.""" + raise NotImplementedError("NullEventBusAdapter does not support bus creation") + + def get_query(self, bus_name: str | None = None) -> EventQuery: + """Return None query.""" + raise NotImplementedError("NullEventBusAdapter does not support query operations") diff --git a/src/bub/events/backends/__init__.py b/src/bub/events/backends/__init__.py new file mode 100644 index 00000000..1c13f20b --- /dev/null +++ b/src/bub/events/backends/__init__.py @@ -0,0 +1,5 @@ +"""Event system backends.""" + +from .eventure import EventureAdapter + +__all__ = ["EventureAdapter"] diff --git a/src/bub/events/backends/eventure.py b/src/bub/events/backends/eventure.py new file mode 100644 index 00000000..3ac8357a --- /dev/null +++ b/src/bub/events/backends/eventure.py @@ -0,0 +1,216 @@ +"""Eventure backend adapter.""" + +from __future__ import annotations + +from eventure import Event, EventBus, EventLog, EventQuery + +from ..exceptions import BusAlreadyExistsError, BusNotFoundError +from ..models import BaseEvent +from ..types import EventHandler, EventType, Subscription, normalize_event_type + + +class EventureAdapter: + """Clean adapter for the eventure event system backend. + + This adapter encapsulates all eventure-specific logic and provides + a clean interface that matches our event system API. + """ + + def __init__(self) -> None: + """Initialize the eventure adapter.""" + self._event_buses: dict[str, EventBus] = {} + self._event_logs: dict[str, EventLog] = {} + self._default_bus_name = "default" + + # Create default bus + self._create_event_bus(self._default_bus_name) + + def _create_event_bus(self, name: str) -> EventBus: + """Create a new event bus. + + Args: + name: Name of the bus to create + + Returns: + The created EventBus instance + + Raises: + BusAlreadyExistsError: If bus already exists + """ + if name in self._event_buses: + raise BusAlreadyExistsError(name) + + event_log = EventLog() + event_bus = EventBus(event_log) + + self._event_buses[name] = event_bus + self._event_logs[name] = event_log + + return event_bus + + def _get_target_bus_name(self, bus_name: str | None) -> str: + """Get the target bus name, defaulting to default bus.""" + return bus_name or self._default_bus_name + + def _ensure_bus_exists(self, bus_name: str) -> None: + """Ensure a bus exists, raising error if not. + + Args: + bus_name: Name of the bus to check + + Raises: + BusNotFoundError: If bus doesn't exist + """ + if bus_name not in self._event_buses: + raise BusNotFoundError(bus_name) + + def publish( + self, + event: BaseEvent, + parent_event: Event | None = None, + bus_name: str | None = None, + ) -> Event: + """Publish an event to the eventure bus. + + Args: + event: The event to publish + parent_event: Optional parent event for cascading + bus_name: Optional bus name to publish to + + Returns: + The published eventure Event object + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + target_bus_name = self._get_target_bus_name(bus_name) + self._ensure_bus_exists(target_bus_name) + + event_bus = self._event_buses[target_bus_name] + + # Use the bus publish method which handles Event creation + return event_bus.publish( + event.get_event_type_value(), + event.model_dump(), + parent_event=parent_event, + ) + + def subscribe( + self, + event_type: EventType, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """Subscribe to an event type. + + Args: + event_type: The event type to subscribe to + handler: The handler function + bus_name: Optional bus name to subscribe to + + Returns: + The subscription object + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + target_bus_name = self._get_target_bus_name(bus_name) + self._ensure_bus_exists(target_bus_name) + + event_bus = self._event_buses[target_bus_name] + + # Normalize event type to string + normalized_type = normalize_event_type(event_type) + + return event_bus.subscribe(normalized_type, handler) # type: ignore[arg-type] + + def subscribe_to_all( + self, + handler: EventHandler, + bus_name: str | None = None, + ) -> Subscription: + """Subscribe to all events. + + Args: + handler: The handler function + bus_name: Optional bus name to subscribe to + + Returns: + The subscription object + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + target_bus_name = self._get_target_bus_name(bus_name) + self._ensure_bus_exists(target_bus_name) + + event_bus = self._event_buses[target_bus_name] + + # Eventure doesn't have subscribe_to_all, so we use a wildcard pattern + # Subscribe to all domains by using "*" pattern + return event_bus.subscribe("*", handler) # type: ignore[arg-type] + + def get_bus(self, bus_name: str | None = None) -> EventBus: + """Get a bus instance. + + Args: + bus_name: Optional bus name + + Returns: + The EventBus instance + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + target_bus_name = self._get_target_bus_name(bus_name) + self._ensure_bus_exists(target_bus_name) + + return self._event_buses[target_bus_name] + + def get_log(self, bus_name: str | None = None) -> EventLog: + """Get a log instance for querying events. + + Args: + bus_name: Optional bus name + + Returns: + The EventLog instance + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + target_bus_name = self._get_target_bus_name(bus_name) + + if target_bus_name not in self._event_logs: + raise BusNotFoundError(target_bus_name) + + return self._event_logs[target_bus_name] + + def create_bus(self, bus_name: str) -> EventBus: + """Create a new bus. + + Args: + bus_name: Name of the bus to create + + Returns: + The created EventBus instance + + Raises: + BusAlreadyExistsError: If bus already exists + """ + return self._create_event_bus(bus_name) + + def get_query(self, bus_name: str | None = None) -> EventQuery: + """Get a query interface for an event log. + + Args: + bus_name: Optional bus name + + Returns: + The EventQuery instance + + Raises: + BusNotFoundError: If specified bus doesn't exist + """ + event_log = self.get_log(bus_name) + return EventQuery(event_log) diff --git a/src/bub/events/bridges.py b/src/bub/events/bridges.py new file mode 100644 index 00000000..ddd9cf4a --- /dev/null +++ b/src/bub/events/bridges.py @@ -0,0 +1,378 @@ +"""Domain bridge interface for decoupled event-driven architecture using Pydantic BaseModel events.""" + +import contextlib +from abc import ABC, abstractmethod +from typing import Any, Optional + +import logfire + +from ..events.models import BaseEvent +from ..events.system import get_event_system +from ..events.types import EventHandler, EventType, Subscription + + +class DomainEventBridge(ABC): + """Abstract interface for domain event emission and handling. + + This allows domains to emit and handle Pydantic BaseEvent instances without + being tightly coupled to any specific event system or other domains. + """ + + @abstractmethod + def publish_event(self, event: BaseEvent) -> None: + """Publish a Pydantic BaseEvent instance.""" + pass + + @abstractmethod + def subscribe_to_event(self, event_type: EventType, handler: EventHandler, domain: str) -> Subscription: + """Subscribe to an event type for a domain.""" + pass + + @abstractmethod + def unsubscribe_from_event(self, subscription: Subscription, domain: str) -> None: + """Unsubscribe from an event using subscription object.""" + pass + + @abstractmethod + def get_domain_state(self, domain: str) -> Optional[dict[str, Any]]: + """Get the current state of a domain.""" + pass + + @abstractmethod + def set_domain_state(self, domain: str, state: dict[str, Any]) -> None: + """Set the state of a domain.""" + pass + + +class NullDomainEventBridge(DomainEventBridge): + """No-op implementation of DomainEventBridge. + + This is useful for testing or when domains need to work without + any event system integration. + """ + + def publish_event(self, event: BaseEvent) -> None: + """No-op event publishing.""" + pass + + def subscribe_to_event(self, event_type: EventType, handler: EventHandler, domain: str) -> Subscription: + """No-op event subscription.""" + + def mock_unsubscribe() -> None: + pass + + return mock_unsubscribe + + def unsubscribe_from_event(self, subscription: Subscription, domain: str) -> None: + """No-op event unsubscription.""" + pass + + def get_domain_state(self, domain: str) -> Optional[dict[str, Any]]: + """No-op state retrieval.""" + return None + + def set_domain_state(self, domain: str, state: dict[str, Any]) -> None: + """No-op state setting.""" + pass + + +class LogfireDomainEventBridge(DomainEventBridge): + """Logfire implementation of DomainEventBridge. + + This bridge logs all domain events and state changes using logfire + for structured logging and better observability. + """ + + def __init__(self, logger_name: str = "domains") -> None: + """Initialize the logfire bridge. + + Args: + logger_name: Name of the logger to use + """ + self._logger_name = logger_name + self._state: dict[str, dict[str, Any]] = {} + self._subscriptions: dict[str, list[Any]] = {} + self._handlers: dict[str, EventHandler] = {} + + def publish_event(self, event: BaseEvent) -> None: + """Log event publishing and trigger handlers.""" + event_type = event.get_event_type_value() + domain = event.get_domain() + + logfire.info( + "Domain event published", + domain=domain, + event_type=event_type, + logger_name=self._logger_name, + ) + + # Trigger any subscribed handlers for this event type + for subscription in self._subscriptions.get(event_type, []): + try: + handler = self._handlers.get(str(id(subscription))) + if handler: + # Convert BaseEvent to Event for eventure compatibility + handler(event) + except Exception: + logfire.exception( + "Handler failed for event", + event_type=event_type, + domain=domain, + logger_name=self._logger_name, + ) + + def subscribe_to_event(self, event_type: EventType, handler: EventHandler, domain: str) -> Subscription: + """Log event subscription and store handler.""" + event_type_str = str(event_type.value) if hasattr(event_type, "value") else str(event_type) + + logfire.info( + "Domain subscribed to event", + domain=domain, + event_type=event_type_str, + logger_name=self._logger_name, + ) + + # Create a subscription function that can be called to unsubscribe + def subscription() -> None: + # Remove from subscriptions + if event_type_str in self._subscriptions: + with contextlib.suppress(ValueError): + self._subscriptions[event_type_str].remove(subscription) + + # Store the handler for later use + self._handlers[str(id(subscription))] = handler + + if event_type_str not in self._subscriptions: + self._subscriptions[event_type_str] = [] + self._subscriptions[event_type_str].append(subscription) + + return subscription + + def unsubscribe_from_event(self, subscription: Subscription, domain: str) -> None: + """Log event unsubscription and remove handler.""" + event_type_str = ( + str(subscription.event_type.value) + if hasattr(subscription, "event_type") and hasattr(subscription.event_type, "value") + else str(getattr(subscription, "event_type", "unknown")) + ) + + logfire.info( + "Domain unsubscribed from event", + domain=domain, + event_type=event_type_str, + logger_name=self._logger_name, + ) + + if event_type_str in self._subscriptions: + with contextlib.suppress(ValueError): + self._subscriptions[event_type_str].remove(subscription) + + def get_domain_state(self, domain: str) -> Optional[dict[str, Any]]: + """Get domain state.""" + return self._state.get(domain) + + def set_domain_state(self, domain: str, state: dict[str, Any]) -> None: + """Set domain state with structured logging.""" + logfire.info( + "Domain state updated", + domain=domain, + state_keys=list(state.keys()), + state_size=len(str(state)), + logger_name=self._logger_name, + ) + self._state[domain] = state + + +class EventSystemDomainBridge(DomainEventBridge): + """Production implementation using the Bub event system. + + This bridge integrates with the existing Bub event system while + providing the decoupled domain interface. + """ + + def __init__(self, bus_name: str = "default") -> None: + """Initialize the event system bridge. + + Args: + bus_name: Name of the event bus to use + """ + self._event_system = get_event_system() + self._bus_name = bus_name + self._state: dict[str, dict[str, Any]] = {} + self._subscriptions: dict[str, list[Any]] = {} + + # Try to create the bus if it doesn't exist + with contextlib.suppress(Exception): + # Check if we can access the adapter to create the bus + if hasattr(self._event_system, "_adapter") and hasattr(self._event_system._adapter, "create_bus"): + self._event_system._adapter.create_bus(bus_name) + + def publish_event(self, event: BaseEvent) -> None: + """Publish event via the event system.""" + try: + self._event_system.publish(event, bus=self._bus_name) + logfire.debug( + "Domain event published via event system", + domain=event.get_domain(), + event_type=event.get_event_type_value(), + ) + except Exception: + logfire.exception( + "Failed to publish domain event via event system", + domain=event.get_domain(), + event_type=event.get_event_type_value(), + ) + + def subscribe_to_event(self, event_type: EventType, handler: EventHandler, domain: str) -> Subscription: + """Subscribe to event via the event system.""" + try: + subscription = self._event_system.subscribe(event_type, handler, bus=self._bus_name) + + # Store subscription for tracking + domain_key = f"{domain}:{event_type}" + if domain_key not in self._subscriptions: + self._subscriptions[domain_key] = [] + self._subscriptions[domain_key].append(subscription) + + logfire.debug( + "Domain subscribed via event system", + domain=domain, + event_type=str(event_type), + ) + + except Exception as e: + # If subscription fails due to bus not found, create a mock subscription + logfire.warning( + "Failed to subscribe via event system, using mock subscription", + domain=domain, + event_type=str(event_type), + error=str(e), + ) + + # Create a mock subscription that does nothing + def mock_unsubscribe() -> None: + pass + + subscription = mock_unsubscribe + + # Store subscription for tracking + domain_key = f"{domain}:{event_type}" + if domain_key not in self._subscriptions: + self._subscriptions[domain_key] = [] + self._subscriptions[domain_key].append(subscription) + + return subscription # type: ignore[return-value] + + def unsubscribe_from_event(self, subscription: Subscription, domain: str) -> None: + """Unsubscribe from event.""" + try: + # Remove from tracking + for _domain_key, subs in self._subscriptions.items(): + if subscription in subs: + subs.remove(subscription) + break + + # Try to call the unsubscribe function if it's callable + if callable(subscription): + with contextlib.suppress(Exception): + subscription() + + logfire.debug( + "Domain unsubscribed via event system", + domain=domain, + ) + + except Exception: + logfire.exception( + "Failed to unsubscribe domain via event system", + domain=domain, + ) + + def get_domain_state(self, domain: str) -> Optional[dict[str, Any]]: + """Get domain state.""" + return self._state.get(domain) + + def set_domain_state(self, domain: str, state: dict[str, Any]) -> None: + """Set domain state.""" + self._state[domain] = state + logfire.debug( + "Updated state via event system", + domain=domain, + state_keys=list(state.keys()), + ) + + +class BaseDomain(ABC): + """Base class for all domains with bridge pattern integration. + + This provides a clean interface for domains to emit Pydantic BaseEvent + instances and handle state without being coupled to specific event systems. + """ + + def __init__(self, bridge: DomainEventBridge, domain_name: str) -> None: + """Initialize domain with event bridge. + + Args: + bridge: Event bridge for communication + domain_name: Name of this domain + """ + self._bridge = bridge + self._domain_name = domain_name + self._state: dict[str, Any] = {} + self._subscriptions: list[Subscription] = [] + self._setup_subscriptions() + + @abstractmethod + def _setup_subscriptions(self) -> None: + """Setup event subscriptions for this domain.""" + pass + + def publish(self, event: BaseEvent) -> None: + """Publish an event through the bridge.""" + self._bridge.publish_event(event) + + def subscribe(self, event_type: EventType, handler: EventHandler) -> Subscription: + """Subscribe to an event type through the bridge.""" + subscription = self._bridge.subscribe_to_event(event_type, handler, self._domain_name) + self._subscriptions.append(subscription) + return subscription + + def unsubscribe(self, subscription: Subscription) -> None: + """Unsubscribe from an event through the bridge.""" + self._bridge.unsubscribe_from_event(subscription, self._domain_name) + with contextlib.suppress(ValueError): + self._subscriptions.remove(subscription) + + def get_state(self) -> dict[str, Any]: + """Get the current state of this domain.""" + return self._state.copy() + + def update_state(self, updates: dict[str, Any]) -> None: + """Update the state of this domain.""" + self._state.update(updates) + self._bridge.set_domain_state(self._domain_name, self._state) + + def set_state(self, state: dict[str, Any]) -> None: + """Set the complete state of this domain.""" + self._state = state.copy() + self._bridge.set_domain_state(self._domain_name, self._state) + + def get_domain_state(self, domain: str) -> Optional[dict[str, Any]]: + """Get the state of another domain.""" + return self._bridge.get_domain_state(domain) + + def cleanup(self) -> None: + """Clean up domain resources.""" + for subscription in self._subscriptions: + self._bridge.unsubscribe_from_event(subscription, self._domain_name) + self._subscriptions.clear() + + @property + def domain_name(self) -> str: + """Get the domain name.""" + return self._domain_name + + @property + def bridge(self) -> DomainEventBridge: + """Get the event bridge.""" + return self._bridge diff --git a/src/bub/events/exceptions.py b/src/bub/events/exceptions.py new file mode 100644 index 00000000..9ee8b7c6 --- /dev/null +++ b/src/bub/events/exceptions.py @@ -0,0 +1,52 @@ +"""Event framework exceptions with clean hierarchy.""" + + +class EventError(Exception): + """Base exception for event-related errors.""" + + +class EventTypeError(EventError): + """Exception for event type validation errors.""" + + +class EventBusError(EventError): + """Exception for event bus operation errors.""" + + +class EventSubscriptionError(EventError): + """Exception for event subscription errors.""" + + +class EventValidationError(EventError): + """Exception for event validation errors.""" + + +class EventAdapterError(EventError): + """Exception for event adapter errors.""" + + +class BusNotFoundError(EventBusError): + """Exception for when a specific event bus is not found.""" + + def __init__(self, bus_name: str) -> None: + """Initialize with bus name.""" + super().__init__(f"Event bus '{bus_name}' not found") + self.bus_name = bus_name + + +class BusAlreadyExistsError(EventBusError): + """Exception for when trying to create a bus that already exists.""" + + def __init__(self, bus_name: str) -> None: + """Initialize with bus name.""" + super().__init__(f"Event bus '{bus_name}' already exists") + self.bus_name = bus_name + + +class SchemaNotFoundError(EventTypeError): + """Exception for when an event schema is not found.""" + + def __init__(self, event_type: str) -> None: + """Initialize with event type.""" + super().__init__(f"No schema registered for event type: {event_type}") + self.event_type = event_type diff --git a/src/bub/events/models.py b/src/bub/events/models.py new file mode 100644 index 00000000..c4ff6449 --- /dev/null +++ b/src/bub/events/models.py @@ -0,0 +1,39 @@ +"""Event model definitions.""" + +from __future__ import annotations + +from abc import ABC +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + +from .types import EventType, normalize_event_type + + +class BaseEvent(BaseModel, ABC): + """Base class for all events in the system. + + All events must inherit from this class and define their event_type. + """ + + event_type: ClassVar[EventType] + + # Allow extra fields for flexibility + model_config = ConfigDict(extra="allow") + + @classmethod + def get_event_type_value(cls) -> str: + """Get the string value of the event type.""" + return normalize_event_type(cls.event_type) + + @classmethod + def get_domain(cls) -> str: + """Get the domain this event belongs to.""" + event_value = cls.get_event_type_value() + return event_value.split(".")[0] if "." in event_value else "unknown" + + @classmethod + def get_action(cls) -> str: + """Get the action this event represents.""" + event_value = cls.get_event_type_value() + return event_value.split(".")[1] if "." in event_value else "unknown" diff --git a/src/bub/events/registry.py b/src/bub/events/registry.py new file mode 100644 index 00000000..7cdd761c --- /dev/null +++ b/src/bub/events/registry.py @@ -0,0 +1,164 @@ +"""Event schema registry and management.""" + +from __future__ import annotations + +from .exceptions import SchemaNotFoundError +from .models import BaseEvent +from .types import EventType, normalize_event_type + + +class EventSchemaRegistry: + """Registry for event schemas and validation.""" + + def __init__(self) -> None: + """Initialize the event schema registry.""" + self._schemas: dict[str, type[BaseEvent]] = {} + self._schemas_by_class: dict[type[BaseEvent], str] = {} + + def register(self, event_class: type[BaseEvent]) -> type[BaseEvent]: + """Register an event class. + + Args: + event_class: The event class to register + + Returns: + The same event class (for decorator usage) + + Raises: + ValueError: If event type is already registered with different class + """ + event_type = event_class.get_event_type_value() + + if event_type in self._schemas: + existing = self._schemas[event_type] + if existing != event_class: + msg = ( + f"Event type '{event_type}' already registered with different class: " + f"{existing.__name__} vs {event_class.__name__}" + ) + raise ValueError(msg) + else: + self._schemas[event_type] = event_class + self._schemas_by_class[event_class] = event_type + + return event_class + + def get_schema(self, event_type: EventType) -> type[BaseEvent] | None: + """Get the schema for an event type. + + Args: + event_type: The event type (string or Enum) + + Returns: + The event class or None if not found + """ + normalized_type = normalize_event_type(event_type) + return self._schemas.get(normalized_type) + + def get_schema_or_raise(self, event_type: EventType) -> type[BaseEvent]: + """Get the schema for an event type, raising exception if not found. + + Args: + event_type: The event type (string or Enum) + + Returns: + The event class + + Raises: + SchemaNotFoundError: If schema not found + """ + schema = self.get_schema(event_type) + if schema is None: + normalized_type = normalize_event_type(event_type) + raise SchemaNotFoundError(normalized_type) + return schema + + def list_schemas(self) -> dict[str, type[BaseEvent]]: + """List all registered schemas. + + Returns: + dictionary mapping event types to their classes + """ + return self._schemas.copy() + + def is_registered(self, event_type: EventType) -> bool: + """Check if an event type is registered. + + Args: + event_type: The event type to check + + Returns: + True if registered, False otherwise + """ + return normalize_event_type(event_type) in self._schemas + + def unregister(self, event_type: EventType) -> bool: + """Unregister an event type. + + Args: + event_type: The event type to unregister + + Returns: + True if was registered and removed, False otherwise + """ + normalized_type = normalize_event_type(event_type) + if normalized_type in self._schemas: + event_class = self._schemas[normalized_type] + del self._schemas[normalized_type] + self._schemas_by_class.pop(event_class, None) + return True + return False + + +# Global schema registry - singleton pattern +_global_registry = EventSchemaRegistry() + + +def register_event(event_class: type[BaseEvent]) -> type[BaseEvent]: + """Register an event class with the global schema registry. + + This is a decorator that can be used to register event classes. + + Args: + event_class: The event class to register + + Returns: + The same event class (for decorator usage) + """ + return _global_registry.register(event_class) + + +def get_event_schema(event_type: EventType) -> type[BaseEvent] | None: + """Get the schema for an event type from global registry. + + Args: + event_type: The event type (string or Enum) + + Returns: + The event class or None if not found + """ + return _global_registry.get_schema(event_type) + + +def get_event_schema_or_raise(event_type: EventType) -> type[BaseEvent]: + """Get the schema for an event type, raising exception if not found. + + Args: + event_type: The event type (string or Enum) + + Returns: + The event class + + Raises: + SchemaNotFoundError: If schema not found + """ + return _global_registry.get_schema_or_raise(event_type) + + +def get_registry() -> EventSchemaRegistry: + """Get the global event schema registry. + + Returns: + The global EventSchemaRegistry instance + """ + return _global_registry diff --git a/src/bub/events/system.py b/src/bub/events/system.py new file mode 100644 index 00000000..0dd6344d --- /dev/null +++ b/src/bub/events/system.py @@ -0,0 +1,227 @@ +"""EventSystem - the main facade for the events framework.""" + +from __future__ import annotations + +from typing import Callable + +from eventure import Event, EventBus, EventLog, EventQuery + +from .adapters import EventBusAdapter +from .backends.eventure import EventureAdapter +from .models import BaseEvent +from .registry import get_event_schema_or_raise +from .types import EventHandler, EventType, Subscription + + +class EventSystem: + """Main facade for the events framework. + + This class provides a clean, high-level API for publishing and subscribing + to events, while hiding the complexity of the underlying adapter. + """ + + def __init__(self, adapter: EventBusAdapter | None = None) -> None: + """Initialize the event system. + + Args: + adapter: Optional event bus adapter. If None, uses EventureAdapter. + """ + self._adapter = adapter or EventureAdapter() + + def publish( + self, + event: BaseEvent | str, + data: dict[str, str | int | float | bool] | None = None, + *, + parent: Event | None = None, + bus: str = "default", + ) -> Event: + """Publish an event. + + Args: + event: Either a BaseEvent instance or event type string + data: Event data (only used if event is a string) + parent: Optional parent event for cascading + bus: Bus name to publish to + + Returns: + Eventure Event object + + Raises: + SchemaNotFoundError: If event type string has no registered schema + """ + if isinstance(event, str): + # Create event from string type and data + event = self._create_event_from_string(event, data) + + return self._adapter.publish(event, parent_event=parent, bus_name=bus) + + def _create_event_from_string(self, event_type: str, data: dict[str, str | int | float | bool] | None) -> BaseEvent: + """Create an event instance from string type and data. + + Args: + event_type: The event type string + data: The event data + + Returns: + A BaseEvent instance + + Raises: + SchemaNotFoundError: If no schema is registered for the event type + """ + event_class = get_event_schema_or_raise(event_type) + event_data = data or {} + return event_class(**event_data) + + def subscribe( + self, + event_type: EventType, + handler: EventHandler | None = None, + *, + bus: str = "default", + ) -> Subscription | Callable[[EventHandler], EventHandler]: + """Subscribe to an event type. + + Can be used as decorator or direct function call. + + Args: + event_type: The event type to subscribe to + handler: Optional handler function (required for direct call) + bus: Bus name to subscribe to + + Returns: + Decorator function or subscription object + """ + if handler is None: + # Used as decorator + return self._create_subscription_decorator(event_type, bus) + else: + # Direct function call + return self._adapter.subscribe(event_type, handler, bus_name=bus) + + def _create_subscription_decorator(self, event_type: EventType, bus: str) -> Callable[[EventHandler], EventHandler]: + """Create a subscription decorator. + + Args: + event_type: The event type to subscribe to + bus: Bus name to subscribe to + + Returns: + Decorator function + """ + + def decorator(func: EventHandler) -> EventHandler: + self._adapter.subscribe(event_type, func, bus_name=bus) + return func + + return decorator + + def subscribe_to_all( + self, + handler: EventHandler | None = None, + *, + bus: str = "default", + ) -> Subscription | Callable[[EventHandler], EventHandler]: + """Subscribe to all events. + + Can be used as decorator or direct function call. + + Args: + handler: Optional handler function (required for direct call) + bus: Bus name to subscribe to + + Returns: + Decorator function or subscription object + """ + if handler is None: + # Used as decorator + return self._create_subscribe_all_decorator(bus) + else: + # Direct function call + return self._adapter.subscribe_to_all(handler, bus_name=bus) + + def _create_subscribe_all_decorator(self, bus: str) -> Callable[[EventHandler], EventHandler]: + """Create a subscribe-to-all decorator. + + Args: + bus: Bus name to subscribe to + + Returns: + Decorator function + """ + + def decorator(func: EventHandler) -> EventHandler: + self._adapter.subscribe_to_all(func, bus_name=bus) + return func + + return decorator + + def get_bus(self, name: str = "default") -> EventBus: + """Get a bus instance. + + Args: + name: Bus name + + Returns: + Eventure EventBus object + """ + return self._adapter.get_bus(name) + + def get_log(self, name: str = "default") -> EventLog: + """Get a log instance. + + Args: + name: Bus name + + Returns: + Eventure EventLog object + """ + return self._adapter.get_log(name) + + def create_bus(self, name: str) -> EventBus: + """Create a new bus. + + Args: + name: Name of the bus to create + + Returns: + Eventure EventBus object + """ + return self._adapter.create_bus(name) + + def get_query(self, name: str = "default") -> EventQuery: + """Get a query interface for an event log. + + Args: + name: Bus name + + Returns: + Eventure EventQuery object + """ + return self._adapter.get_query(name) + + +# Global event system instance - singleton pattern +_global_event_system: EventSystem | None = None + + +def get_event_system() -> EventSystem: + """Get the global event system instance. + + Returns: + The global EventSystem instance + """ + global _global_event_system + if _global_event_system is None: + _global_event_system = EventSystem() + return _global_event_system + + +def set_event_system(event_system: EventSystem) -> None: + """Set the global event system instance. + + Args: + event_system: The EventSystem instance to set as global + """ + global _global_event_system + _global_event_system = event_system diff --git a/src/bub/events/types.py b/src/bub/events/types.py new file mode 100644 index 00000000..248d1c3a --- /dev/null +++ b/src/bub/events/types.py @@ -0,0 +1,61 @@ +"""Event type definitions and utilities.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Callable, TypeVar, Union + +if TYPE_CHECKING: + from .models import BaseEvent + +# Type aliases for better readability and consistency +EventType = Union[str, Enum] +EventHandler = Callable[["BaseEvent"], None] # Use forward reference to avoid circular import +Subscription = Callable[[], None] # Eventure returns unsubscribe function + +# Type variable for event classes +EventT = TypeVar("EventT", bound="BaseEvent") # Use forward reference + + +class DomainEventType(str, Enum): + """Base class for domain event types with standardized naming. + + Event types should follow the pattern 'domain.action' for consistency. + """ + + @property + def domain(self) -> str: + """Extract domain from event type.""" + return str(self.value).split(".")[0] + + @property + def action(self) -> str: + """Extract action from event type.""" + return str(self.value).split(".")[1] + + +def normalize_event_type(event_type: EventType) -> str: + """Normalize event type to string representation. + + Args: + event_type: Event type as string or Enum + + Returns: + Normalized string representation + """ + if isinstance(event_type, Enum): + return str(event_type.value) + return str(event_type) + + +def is_valid_event_type(event_type: EventType) -> bool: + """Validate event type format. + + Args: + event_type: Event type to validate + + Returns: + True if valid, False otherwise + """ + normalized = normalize_event_type(event_type) + return bool(normalized and "." in normalized) diff --git a/src/bub/tools/__init__.py b/src/bub/tools/__init__.py deleted file mode 100644 index 1ab78d04..00000000 --- a/src/bub/tools/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Tools package for Bub.""" - -from .file_edit import FileEditTool -from .file_read import FileReadTool -from .file_write import FileWriteTool -from .run_command import RunCommandTool - -__all__ = [ - "FileEditTool", - "FileReadTool", - "FileWriteTool", - "RunCommandTool", -] diff --git a/src/bub/tools/file_edit.py b/src/bub/tools/file_edit.py deleted file mode 100644 index 68a35a31..00000000 --- a/src/bub/tools/file_edit.py +++ /dev/null @@ -1,176 +0,0 @@ -"""LLM-friendly File edit tool for Bub.""" - -from typing import Any, Literal, Optional - -from pydantic import Field - -from ..agent.context import Context -from ..agent.tools import Tool, ToolResult - - -class FileEditTool(Tool): - """Tool for fine-grained editing of file contents in the workspace. - - Usage example: - Action: edit_file - Action Input: {"path": "config.txt", "operation": "replace_lines", "start_line": 1, "end_line": 3, "content": "new content"} - - Parameters: - path: The relative or absolute path to the file to edit. - operation: The type of edit operation. - content: The content to use in the operation. - start_line: Start line number (1-based, inclusive). For line-based ops. - end_line: End line number (1-based, inclusive). For line-based ops. - match_text: Text to match for replace_text operation. - - Supported operations: - - replace_lines: Replace lines in a given range (1-based, inclusive) - - replace_text: Replace all occurrences of match_text with replace_text - - insert_after: Insert content after a given line number - - insert_before: Insert content before a given line number - - delete_lines: Delete lines in a given range (1-based, inclusive) - - append: Append content to the end of the file - - prepend: Prepend content to the beginning of the file - """ - - name: str = Field(default="edit_file", description="The internal name of the tool") - display_name: str = Field(default="Edit File", description="The user-friendly display name") - description: str = Field( - default="Edit file content with fine-grained operations", description="Description of what the tool does" - ) - - path: str = Field(..., description="The relative or absolute path to the file to edit.") - operation: Literal[ - "replace_lines", "replace_text", "insert_after", "insert_before", "delete_lines", "append", "prepend" - ] = Field(..., description="The type of edit operation.") - content: Optional[str] = Field(None, description="The content to use in the operation.") - start_line: Optional[int] = Field(None, description="Start line number (1-based, inclusive). For line-based ops.") - end_line: Optional[int] = Field(None, description="End line number (1-based, inclusive). For line-based ops.") - match_text: Optional[str] = Field(None, description="Text to match for replace_text operation.") - replace_text: Optional[str] = Field(None, description="Replacement text for replace_text operation.") - line_number: Optional[int] = Field(None, description="Line number for insert_after/insert_before (1-based).") - - @classmethod - def get_tool_info(cls) -> dict[str, Any]: - return { - "name": "edit_file", - "display_name": "Edit File", - "description": "Edit file content with fine-grained operations", - } - - def execute(self, context: Context) -> ToolResult: - try: - from pathlib import Path - - from .utils import sanitize_path - - file_path = Path(self.path) - if not file_path.is_absolute(): - file_path = context.workspace_path / file_path - if not file_path.exists(): - safe_path = sanitize_path(file_path) - return ToolResult(success=False, data=None, error=f"File not found: {safe_path}") - - lines = file_path.read_text(encoding="utf-8").splitlines(keepends=True) - dispatch = { - "replace_lines": self._replace_lines, - "replace_text": self._replace_text, - "insert_after": self._insert_after, - "insert_before": self._insert_before, - "delete_lines": self._delete_lines, - "append": self._append, - "prepend": self._prepend, - } - func = dispatch.get(self.operation) - if func is None: - return ToolResult(success=False, data=None, error=f"Unknown operation: {self.operation}") - return func(lines, file_path) - except Exception as e: - return ToolResult(success=False, data=None, error=f"Error editing file: {e!s}") - - def _replace_lines(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.start_line is None or self.end_line is None or self.content is None: - return ToolResult( - success=False, data=None, error="start_line, end_line, and content are required for replace_lines." - ) - if not (1 <= self.start_line <= self.end_line <= len(lines)): - return ToolResult(success=False, data=None, error="Invalid line range.") - new_content_lines = self.content.splitlines(keepends=True) - new_lines = lines[: self.start_line - 1] + new_content_lines + lines[self.end_line :] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _replace_text(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.match_text is None or self.replace_text is None: - return ToolResult( - success=False, data=None, error="match_text and replace_text are required for replace_text." - ) - file_text = "".join(lines) - new_text = file_text.replace(self.match_text, self.replace_text) - new_lines = new_text.splitlines(keepends=True) - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _insert_after(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.line_number is None or self.content is None: - return ToolResult(success=False, data=None, error="line_number and content are required for insert_after.") - if not (0 <= self.line_number <= len(lines)): - return ToolResult(success=False, data=None, error="Invalid line_number.") - new_content_lines = self.content.splitlines(keepends=True) - new_lines = lines[: self.line_number] + new_content_lines + lines[self.line_number :] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _insert_before(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.line_number is None or self.content is None: - return ToolResult(success=False, data=None, error="line_number and content are required for insert_before.") - if not (1 <= self.line_number <= len(lines) + 1): - return ToolResult(success=False, data=None, error="Invalid line_number.") - new_content_lines = self.content.splitlines(keepends=True) - new_lines = lines[: self.line_number - 1] + new_content_lines + lines[self.line_number - 1 :] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _delete_lines(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.start_line is None or self.end_line is None: - return ToolResult(success=False, data=None, error="start_line and end_line are required for delete_lines.") - if not (1 <= self.start_line <= self.end_line <= len(lines)): - return ToolResult(success=False, data=None, error="Invalid line range.") - new_lines = lines[: self.start_line - 1] + lines[self.end_line :] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _append(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.content is None: - return ToolResult(success=False, data=None, error="content is required for append.") - new_lines = [*lines, self.content if self.content.endswith("\n") else self.content + "\n"] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) - - def _prepend(self, lines: list[str], file_path: Any) -> ToolResult: - from .utils import sanitize_path - - if self.content is None: - return ToolResult(success=False, data=None, error="content is required for prepend.") - new_lines = [self.content if self.content.endswith("\n") else self.content + "\n", *lines] - file_path.write_text("".join(new_lines), encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File edited successfully: {safe_path}", error=None) diff --git a/src/bub/tools/file_read.py b/src/bub/tools/file_read.py deleted file mode 100644 index fa7abcea..00000000 --- a/src/bub/tools/file_read.py +++ /dev/null @@ -1,57 +0,0 @@ -"""File read tool for Bub.""" - -from typing import Any - -from pydantic import Field - -from ..agent.context import Context -from ..agent.tools import Tool, ToolResult - - -class FileReadTool(Tool): - """Tool for reading file contents from the workspace. - - Usage example: - Action: read_file - Action Input: {"path": "README.md"} - - Parameters: - path: The relative or absolute path to the file to read. - """ - - name: str = Field(default="read_file", description="The internal name of the tool") - display_name: str = Field(default="Read File", description="The user-friendly display name") - description: str = Field( - default="Read the contents of a file from the workspace", description="Description of what the tool does" - ) - - path: str = Field(..., description="The relative or absolute path to the file to read.") - - @classmethod - def get_tool_info(cls) -> dict[str, Any]: - """Get tool metadata.""" - return { - "name": "read_file", - "display_name": "Read File", - "description": "Read the contents of a file from the workspace", - } - - def execute(self, context: Context) -> ToolResult: - """Execute the file read operation.""" - try: - from pathlib import Path - - from .utils import sanitize_path - - file_path = Path(self.path) - if not file_path.is_absolute(): - file_path = context.workspace_path / file_path - - if not file_path.exists(): - safe_path = sanitize_path(file_path) - return ToolResult(success=False, data=None, error=f"File not found: {safe_path}") - - content = file_path.read_text(encoding="utf-8") - return ToolResult(success=True, data=content, error=None) - except Exception as e: - return ToolResult(success=False, data=None, error=f"Error reading file: {e!s}") diff --git a/src/bub/tools/file_write.py b/src/bub/tools/file_write.py deleted file mode 100644 index e938e25d..00000000 --- a/src/bub/tools/file_write.py +++ /dev/null @@ -1,59 +0,0 @@ -"""File write tool for Bub.""" - -from typing import Any - -from pydantic import Field - -from ..agent.context import Context -from ..agent.tools import Tool, ToolResult - - -class FileWriteTool(Tool): - """Tool for writing content to files in the workspace. - - Usage example: - Action: write_file - Action Input: {"path": "output.txt", "content": "Hello, World!"} - - Parameters: - path: The relative or absolute path to the file to write. - content: The text content to write to the file. - """ - - name: str = Field(default="write_file", description="The internal name of the tool") - display_name: str = Field(default="Write File", description="The user-friendly display name") - description: str = Field( - default="Write content to a file in the workspace", description="Description of what the tool does" - ) - - path: str = Field(..., description="The relative or absolute path to the file to write.") - content: str = Field(..., description="The text content to write to the file.") - - @classmethod - def get_tool_info(cls) -> dict[str, Any]: - """Get tool metadata.""" - return { - "name": "write_file", - "display_name": "Write File", - "description": "Write content to a file in the workspace", - } - - def execute(self, context: Context) -> ToolResult: - """Execute the file write operation.""" - try: - from pathlib import Path - - from .utils import sanitize_path - - file_path = Path(self.path) - if not file_path.is_absolute(): - file_path = context.workspace_path / file_path - - # Create parent directories if they don't exist - file_path.parent.mkdir(parents=True, exist_ok=True) - - file_path.write_text(self.content, encoding="utf-8") - safe_path = sanitize_path(file_path) - return ToolResult(success=True, data=f"File written successfully: {safe_path}", error=None) - except Exception as e: - return ToolResult(success=False, data=None, error=f"Error writing file: {e!s}") diff --git a/src/bub/tools/run_command.py b/src/bub/tools/run_command.py deleted file mode 100644 index ce54e6b2..00000000 --- a/src/bub/tools/run_command.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Command execution tool for Bub.""" - -import shlex -import subprocess -from typing import Any, ClassVar, Optional - -from pydantic import Field - -from ..agent.context import Context -from ..agent.tools import Tool, ToolResult - - -class RunCommandTool(Tool): - """Tool for executing terminal commands in the workspace. - - Usage example: - Action: run_command - Action Input: {"command": "ls -la"} - - Parameters: - command: The shell command to execute (e.g., 'ls', 'cat file.txt'). - cwd: Optional. The working directory to run the command in. Defaults to workspace root. - timeout: Optional. The timeout in seconds for the command to run. Defaults to 30 seconds. - """ - - name: str = Field(default="run_command", description="The internal name of the tool") - display_name: str = Field(default="Run Command", description="The user-friendly display name") - description: str = Field( - default="Execute a terminal command in the workspace", description="Description of what the tool does" - ) - - command: str = Field(..., description="The shell command to execute, e.g., 'ls', 'cat file.txt'.") - cwd: Optional[str] = Field( - default=None, description="Optional. The working directory to run the command in. Defaults to workspace root." - ) - timeout: int = Field( - default=30, description="The timeout in seconds for the command to run. Defaults to 30 seconds." - ) - - # List of dangerous commands that should be blocked - DANGEROUS_COMMANDS: ClassVar[set[str]] = { - "rm", - "del", - "format", - "mkfs", - "dd", - "shred", - "wipe", - "fdisk", - "chmod", - "chown", - "sudo", - "su", - "passwd", - "useradd", - "userdel", - "systemctl", - "service", - "init", - "killall", - "pkill", - "kill", - } - - @classmethod - def get_tool_info(cls) -> dict[str, Any]: - """Get tool metadata.""" - return { - "name": "run_command", - "display_name": "Run Command", - "description": "Execute a terminal command in the workspace", - } - - def _validate_command(self) -> Optional[str]: - """Validate command for security.""" - # Check for dangerous commands - cmd_parts = shlex.split(self.command.lower()) - if not cmd_parts: - return "Empty command" - - base_cmd = cmd_parts[0] - if base_cmd in self.DANGEROUS_COMMANDS: - return f"Dangerous command blocked: {base_cmd}" - - # Check for shell injection attempts - dangerous_chars = [";", "&&", "||", "|", ">", "<", "`", "$(", "eval", "exec"] - for char in dangerous_chars: - if char in self.command: - return f"Potentially dangerous command pattern: {char}" - - return None - - def execute(self, context: Context) -> ToolResult: - """Execute the command.""" - try: - # Validate command first - validation_error = self._validate_command() - if validation_error: - return ToolResult(success=False, data=None, error=validation_error) - - working_dir = self.cwd - if not working_dir: - working_dir = str(context.workspace_path) - - cmd_parts = shlex.split(self.command) - result = subprocess.run( # noqa: S603 - cmd_parts, - cwd=working_dir, - capture_output=True, - text=True, - timeout=self.timeout, - ) - return ToolResult( - success=(result.returncode == 0), - data={"stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}, - error=None if result.returncode == 0 else f"Command failed with return code {result.returncode}", - ) - except subprocess.TimeoutExpired: - return ToolResult(success=False, data=None, error="Command timed out after 30 seconds") - except Exception as e: - # detailed feedback for all exceptions - import traceback - - tb = traceback.format_exc() - return ToolResult(success=False, data=None, error=f"Error executing command: {e!s}\nTraceback:\n{tb}") diff --git a/src/bub/tools/utils.py b/src/bub/tools/utils.py deleted file mode 100644 index fcb4bf85..00000000 --- a/src/bub/tools/utils.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Utility functions for Bub tools.""" - -from pathlib import Path -from typing import Union - - -def sanitize_path(path: Union[str, Path]) -> str: - """Convert absolute path to relative path from home directory for privacy. - - Args: - path: The path to sanitize - - Returns: - A privacy-safe path representation - """ - path = Path(path).resolve() - home = Path.home() - - if path == home: - return "~" - elif path.is_relative_to(home): - return str("~" / path.relative_to(home)) - elif path == Path("/"): - return "/" - else: - # For other absolute paths, show relative to current working directory - try: - return str(path.relative_to(Path.cwd())) - except ValueError: - # If not relative to cwd, show just the name - return path.name or str(path) diff --git a/src/bub/utils/__init__.py b/src/bub/utils/__init__.py new file mode 100644 index 00000000..971b96ac --- /dev/null +++ b/src/bub/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utilities for Bub.""" + +from .logging import configure_logfire + +__all__ = ["configure_logfire"] diff --git a/src/bub/utils/logging.py b/src/bub/utils/logging.py new file mode 100644 index 00000000..0afbef1c --- /dev/null +++ b/src/bub/utils/logging.py @@ -0,0 +1,19 @@ +"""Logging utilities for Bub.""" + +import logfire + + +def configure_logfire(level: str = "INFO", log_format: str = "text") -> None: + """Configure Logfire for structured logging. + + Args: + level: Log level (DEBUG, INFO, WARNING, ERROR) + log_format: Log format (text, json) + """ + logfire.configure( + console=logfire.ConsoleOptions( + colors="auto", + span_style="indented", + ), + send_to_logfire=False, # Disable for local demo + ) diff --git a/stubs/__init__.py b/stubs/__init__.py new file mode 100644 index 00000000..74b2e196 --- /dev/null +++ b/stubs/__init__.py @@ -0,0 +1 @@ +"""Type stubs package for external libraries.""" diff --git a/stubs/eventure.pyi b/stubs/eventure.pyi new file mode 100644 index 00000000..dd7360ce --- /dev/null +++ b/stubs/eventure.pyi @@ -0,0 +1,73 @@ +"""Type stubs for eventure library.""" + +from typing import Any, Callable + +# Type aliases +EventHandler = Callable[["Event"], None] +Subscription = Callable[[], None] + +class Event: + """Event class from eventure library.""" + + def __init__( + self, + tick: int, + timestamp: float, + type: str, # noqa: A002 + data: dict[str, Any], + id: str | None = None, # noqa: A002 + parent_id: str | None = None, + ) -> None: ... + @property + def id(self) -> str: ... + @property + def parent_id(self) -> str | None: ... + @property + def tick(self) -> int: ... + @property + def timestamp(self) -> float: ... + @property + def type(self) -> str: ... + @property + def data(self) -> dict[str, Any]: ... + def to_json(self) -> str: ... + @classmethod + def from_json(cls, json_str: str) -> Event: ... + +class EventLog: + """Event log class from eventure library.""" + + def __init__(self) -> None: ... + def add_event(self, event: Event) -> None: ... + def get_events(self) -> list[Event]: ... + def clear(self) -> None: ... + +class EventBus: + """Event bus class from eventure library.""" + + def __init__(self, event_log: EventLog) -> None: ... + def publish(self, event_type: str, data: dict[str, Any], parent_event: Event | None = None) -> Event: ... + def subscribe(self, event_type: str, handler: EventHandler) -> Subscription: ... + def dispatch(self, event: Event) -> None: ... + @property + def event_log(self) -> EventLog: ... + @property + def subscribers(self) -> dict[str, list[EventHandler]]: ... + +class EventQuery: + """Event query class from eventure library.""" + + def __init__(self, event_log: EventLog) -> None: ... + def get_events_by_type(self, event_type: str) -> list[Event]: ... + def get_events_by_data(self, key: str, value: Any) -> list[Event]: ... + def get_events_at_tick(self, tick: int) -> list[Event]: ... + def get_root_events(self) -> list[Event]: ... + def get_child_events(self, parent_id: str) -> list[Event]: ... + def get_cascade_events(self, event_id: str) -> list[Event]: ... + def count_events_by_type(self, event_type: str) -> int: ... + def print_event_cascade(self) -> None: ... + def print_single_cascade(self, event: Event) -> None: ... + def print_event_details(self, event: Event) -> None: ... + @property + def event_log(self) -> EventLog: ... + def __iter__(self) -> list[Event]: ... diff --git a/tests/test_bub.py b/tests/test_bub.py index 149f00cd..00df6f91 100644 --- a/tests/test_bub.py +++ b/tests/test_bub.py @@ -2,9 +2,18 @@ from unittest.mock import Mock, patch -from bub.agent import Agent, Context, ToolExecutor, ToolRegistry, ToolResult from bub.config import get_settings -from bub.tools import FileEditTool, FileReadTool, FileWriteTool, RunCommandTool +from bub.core.agent import Agent +from bub.core.tools import ( + FileEditTool, + FileReadTool, + FileWriteTool, + RunCommandTool, + ToolExecutor, + ToolRegistry, + ToolResult, +) +from bub.core.tools.base import AgentContext class TestSettings: @@ -60,7 +69,7 @@ class TestTools: def test_file_write_tool(self, tmp_path): """Test file write tool.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = FileWriteTool(path="test.txt", content="Hello, World!") result = tool.execute(context) @@ -74,7 +83,7 @@ def test_file_read_tool(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello, World!") - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = FileReadTool(path="test.txt") result = tool.execute(context) @@ -83,7 +92,7 @@ def test_file_read_tool(self, tmp_path): def test_file_read_tool_not_found(self, tmp_path): """Test file read tool with non-existent file.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = FileReadTool(path="nonexistent.txt") result = tool.execute(context) @@ -96,7 +105,7 @@ def test_file_edit_tool(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello, World!") - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = FileEditTool(path="test.txt", operation="replace_lines", start_line=1, end_line=1, content="Hello, Bub!") result = tool.execute(context) @@ -109,7 +118,7 @@ def test_file_edit_tool_text_not_found(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello, World!") - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = FileEditTool(path="test.txt", operation="replace_lines", start_line=1, end_line=1, content="Hello, Bub!") result = tool.execute(context) @@ -119,7 +128,7 @@ def test_file_edit_tool_text_not_found(self, tmp_path): def test_command_tool_success(self, tmp_path): """Test command tool with successful command.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = RunCommandTool(command="echo 'Hello, World!'") result = tool.execute(context) @@ -129,7 +138,7 @@ def test_command_tool_success(self, tmp_path): def test_command_tool_failure(self, tmp_path): """Test command tool with failing command.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = RunCommandTool(command="nonexistent_command") result = tool.execute(context) @@ -139,16 +148,16 @@ def test_command_tool_failure(self, tmp_path): def test_command_tool_dangerous_command(self, tmp_path): """Test command tool blocks dangerous commands.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = RunCommandTool(command="rm -rf /") result = tool.execute(context) assert not result.success - assert "Dangerous command blocked" in result.error + assert "Command blocked" in result.error def test_command_tool_shell_injection(self, tmp_path): """Test command tool blocks shell injection.""" - context = Context(workspace_path=tmp_path) + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) tool = RunCommandTool(command="echo 'test'; rm -rf /") result = tool.execute(context) @@ -159,19 +168,17 @@ def test_command_tool_shell_injection(self, tmp_path): class TestToolRegistry: """Test tool registry.""" - def test_tool_registry_list_tools(self): + def test_tool_registry_list_tools(self, tmp_path): """Test listing available tools.""" - registry = ToolRegistry() - registry.register_default_tools() + registry = ToolRegistry(workspace_path=tmp_path) tools = registry.list_tools() expected_tools = ["read_file", "write_file", "edit_file", "run_command"] assert set(tools) == set(expected_tools) - def test_tool_registry_get_tool(self): + def test_tool_registry_get_tool(self, tmp_path): """Test getting tool classes.""" - registry = ToolRegistry() - registry.register_default_tools() + registry = ToolRegistry(workspace_path=tmp_path) assert registry.get_tool("read_file") == FileReadTool assert registry.get_tool("write_file") == FileWriteTool @@ -179,9 +186,9 @@ def test_tool_registry_get_tool(self): assert registry.get_tool("run_command") == RunCommandTool assert registry.get_tool("nonexistent") is None - def test_tool_registry_get_schema_nonexistent(self): + def test_tool_registry_get_schema_nonexistent(self, tmp_path): """Test getting schema for non-existent tool.""" - registry = ToolRegistry() + registry = ToolRegistry(workspace_path=tmp_path) assert registry.get_tool_schema("nonexistent") is None @@ -190,17 +197,16 @@ class TestToolExecutor: def test_tool_executor_creation(self, tmp_path): """Test creating tool executor.""" - context = Context(workspace_path=tmp_path) - context.tool_registry = ToolRegistry() + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) + context.tool_registry = ToolRegistry(workspace_path=tmp_path) executor = ToolExecutor(context) assert executor.context == context assert executor.tool_registry is not None def test_tool_executor_execute_tool_success(self, tmp_path): """Test successful tool execution.""" - context = Context(workspace_path=tmp_path) - context.tool_registry = ToolRegistry() - context.tool_registry.register_default_tools() + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) + context.tool_registry = ToolRegistry(workspace_path=tmp_path) executor = ToolExecutor(context) result = executor.execute_tool("write_file", path="test.txt", content="test") @@ -209,8 +215,8 @@ def test_tool_executor_execute_tool_success(self, tmp_path): def test_tool_executor_execute_tool_not_found(self, tmp_path): """Test tool execution with non-existent tool.""" - context = Context(workspace_path=tmp_path) - context.tool_registry = ToolRegistry() + context = AgentContext(provider="test", model_name="test-model", api_key="test-key", workspace_path=tmp_path) + context.tool_registry = ToolRegistry(workspace_path=tmp_path) executor = ToolExecutor(context) result = executor.execute_tool("nonexistent_tool") @@ -225,7 +231,7 @@ def test_agent_creation(self, tmp_path): """Test creating an agent.""" agent = Agent(provider="openai", model_name="test-model", api_key="test-key", workspace_path=tmp_path) assert agent.api_key == "test-key" - assert agent.model == "openai/test-model" + assert agent.model == "test-model" # Fixed: model doesn't include provider prefix def test_agent_reset_conversation(self, tmp_path): """Test resetting conversation.""" @@ -235,7 +241,7 @@ def test_agent_reset_conversation(self, tmp_path): agent.reset_conversation() assert len(agent.conversation_history) == 0 - @patch("bub.agent.core.completion") + @patch("bub.core.agent.completion") def test_agent_chat_no_tools(self, mock_completion, tmp_path): """Test agent chat without tool calls.""" # Mock the completion response @@ -250,21 +256,21 @@ def test_agent_chat_no_tools(self, mock_completion, tmp_path): assert response == "Hello, I'm Bub!" assert len(agent.conversation_history) == 2 # user + assistant - @patch("bub.agent.core.completion") + @patch("bub.core.agent.completion") def test_agent_chat_with_tools(self, mock_completion, tmp_path): """Test agent chat with tool calls.""" # Mock responses: first with tool call, then final response mock_response1 = Mock() mock_response1.choices = [Mock()] - mock_response1.choices[ - 0 - ].message.content = ( - '```tool\n{"tool": "write_file", "parameters": {"path": "test.txt", "content": "test"}}\n```' + mock_response1.choices[0].message.content = ( + "Thought: I need to create a test file. Yes, I should use the write_file tool.\n" + "Action: write_file\n" + 'Action Input: {"path": "test.txt", "content": "test"}' ) mock_response2 = Mock() mock_response2.choices = [Mock()] - mock_response2.choices[0].message.content = "File created successfully!" + mock_response2.choices[0].message.content = "Final Answer: File created successfully!" mock_completion.side_effect = [mock_response1, mock_response2] @@ -279,7 +285,7 @@ def test_agent_extract_tool_calls(self, tmp_path): agent = Agent(provider="openai", model_name="gpt-3.5-turbo", api_key="test-key", workspace_path=tmp_path) response = 'Here\'s the result: ```tool\n{"tool": "read_file", "parameters": {"path": "test.txt"}}\n```' - tool_calls = agent.tool_executor.extract_tool_calls(response) + tool_calls = ToolExecutor(agent.context).extract_tool_calls(response) assert len(tool_calls) == 1 assert tool_calls[0]["tool"] == "read_file" assert tool_calls[0]["parameters"]["path"] == "test.txt" @@ -289,7 +295,7 @@ def test_agent_extract_tool_calls_invalid_json(self, tmp_path): agent = Agent(provider="openai", model_name="gpt-3.5-turbo", api_key="test-key", workspace_path=tmp_path) response = "Here's the result: ```tool\ninvalid json\n```" - tool_calls = agent.tool_executor.extract_tool_calls(response) + tool_calls = ToolExecutor(agent.context).extract_tool_calls(response) assert len(tool_calls) == 0 def test_agent_execute_tool_calls(self, tmp_path): @@ -297,6 +303,9 @@ def test_agent_execute_tool_calls(self, tmp_path): agent = Agent(provider="openai", model_name="gpt-3.5-turbo", api_key="test-key", workspace_path=tmp_path) tool_calls = [{"tool": "write_file", "parameters": {"path": "test.txt", "content": "test"}}] - result = agent.tool_executor.execute_tool_calls(tool_calls) + # Ensure the tool registry is properly set up in the context + agent.context.tool_registry = agent.tool_registry + + result = ToolExecutor(agent.context).execute_tool_calls(tool_calls) assert "Observation:" in result assert (tmp_path / "test.txt").exists() diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 00000000..10ba87af --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,455 @@ +"""Simple tests for Bub's event system - Core Patterns and Features. + +These tests verify the essential event system capabilities: +1. Core event-driven patterns +2. Multi-cross-bus communication +3. Event visualization and cascade analysis +""" + +import time + +import pytest + +from bub.events import ( + BaseEvent, + EventSystem, + publish, + register_event, + set_event_system, + subscribe, +) +from bub.events.types import DomainEventType + +# ============================================================================ +# SIMPLE TEST EVENT TYPES +# ============================================================================ + + +class MockEventType(DomainEventType): + """Simple test event types.""" + + TEST_ACTION = "test.action" + TEST_RESPONSE = "test.response" + TEST_PROCESSED = "test.processed" + + +@register_event +class MockActionEvent(BaseEvent): + """Test action event.""" + + event_type = MockEventType.TEST_ACTION + action: str + value: int + + +@register_event +class MockResponseEvent(BaseEvent): + """Test response event.""" + + event_type = MockEventType.TEST_RESPONSE + response: str + value: int + + +@register_event +class MockProcessedEvent(BaseEvent): + """Test processed event.""" + + event_type = MockEventType.TEST_PROCESSED + result: str + value: int + + +# ============================================================================ +# CORE EVENT-DRIVEN PATTERN TESTS +# ============================================================================ + + +class TestCoreEventPatterns: + """Test core event-driven patterns.""" + + def setup_method(self): + """Reset event system before each test.""" + set_event_system(EventSystem()) + + def test_basic_event_flow(self): + """Test basic event flow: action → response.""" + events_received = [] + + @subscribe(MockEventType.TEST_ACTION) + def handle_action(event): + event_data = event.data + events_received.append(f"action:{event_data['action']}") + + # Auto-generate response + response = MockResponseEvent(response=f"Processed {event_data['action']}", value=event_data["value"] * 2) + publish(response) + + @subscribe(MockEventType.TEST_RESPONSE) + def handle_response(event): + event_data = event.data + events_received.append(f"response:{event_data['response']}") + + # Trigger flow + action = MockActionEvent(action="click", value=5) + publish(action) + + # Verify flow + assert len(events_received) == 2 + assert events_received[0] == "action:click" + assert events_received[1] == "response:Processed click" + + def test_event_cascade(self): + """Test event cascade: action → response → processed.""" + cascade_events = [] + + @subscribe(MockEventType.TEST_ACTION) + def trigger_cascade(event): + event_data = event.data + cascade_events.append(f"1:action:{event_data['action']}") + + # Trigger response + response = MockResponseEvent(response=f"Handled {event_data['action']}", value=event_data["value"]) + publish(response) + + @subscribe(MockEventType.TEST_RESPONSE) + def continue_cascade(event): + event_data = event.data + cascade_events.append(f"2:response:{event_data['response']}") + + # Trigger processed + processed = MockProcessedEvent(result=f"Completed {event_data['response']}", value=event_data["value"] + 10) + publish(processed) + + @subscribe(MockEventType.TEST_PROCESSED) + def end_cascade(event): + event_data = event.data + cascade_events.append(f"3:processed:{event_data['result']}") + + # Start cascade + action = MockActionEvent(action="submit", value=10) + publish(action) + + # Verify cascade + assert len(cascade_events) == 3 + assert cascade_events[0] == "1:action:submit" + assert cascade_events[1] == "2:response:Handled submit" + assert cascade_events[2] == "3:processed:Completed Handled submit" + + def test_multiple_subscribers(self): + """Test multiple subscribers to same event.""" + handler1_events = [] + handler2_events = [] + + @subscribe(MockEventType.TEST_ACTION) + def handler1(event): + handler1_events.append(event.data["action"]) + + @subscribe(MockEventType.TEST_ACTION) + def handler2(event): + handler2_events.append(event.data["action"]) + + # Publish event + action = MockActionEvent(action="test", value=1) + publish(action) + + # Both handlers should receive the event + assert handler1_events == ["test"] + assert handler2_events == ["test"] + + +# ============================================================================ +# MULTI-CROSS-BUS COMMUNICATION TESTS +# ============================================================================ + + +class TestMultiBusCommunication: + """Test multi-cross-bus communication.""" + + def setup_method(self): + """Reset event system before each test.""" + set_event_system(EventSystem()) + + def test_cross_bus_event_flow(self): + """Test events flowing across multiple buses.""" + # Create separate event systems + bus1 = EventSystem() + bus2 = EventSystem() + + cross_bus_events = [] + + # Bus 1 handler + @bus1.subscribe(MockEventType.TEST_ACTION) + def bus1_handler(event): + event_data = event.data + cross_bus_events.append(f"bus1:{event_data['action']}") + + # Send to bus 2 + response = MockResponseEvent(response=f"From bus1: {event_data['action']}", value=event_data["value"]) + bus2.publish(response) + + # Bus 2 handler + @bus2.subscribe(MockEventType.TEST_RESPONSE) + def bus2_handler(event): + event_data = event.data + cross_bus_events.append(f"bus2:{event_data['response']}") + + # Start flow on bus 1 + action = MockActionEvent(action="cross_bus_test", value=5) + bus1.publish(action) + + # Verify cross-bus flow + assert len(cross_bus_events) == 2 + assert cross_bus_events[0] == "bus1:cross_bus_test" + assert cross_bus_events[1] == "bus2:From bus1: cross_bus_test" + + def test_three_bus_cascade(self): + """Test cascade across three buses.""" + bus1 = EventSystem() + bus2 = EventSystem() + bus3 = EventSystem() + + cascade_log = [] + + # Bus 1: Action + @bus1.subscribe(MockEventType.TEST_ACTION) + def bus1_action(event): + event_data = event.data + cascade_log.append(f"bus1:action:{event_data['action']}") + + # Send to bus 2 + response = MockResponseEvent(response=f"Bus1 processed {event_data['action']}", value=event_data["value"]) + bus2.publish(response) + + # Bus 2: Response + @bus2.subscribe(MockEventType.TEST_RESPONSE) + def bus2_response(event): + event_data = event.data + cascade_log.append(f"bus2:response:{event_data['response']}") + + # Send to bus 3 + processed = MockProcessedEvent( + result=f"Bus2 completed {event_data['response']}", value=event_data["value"] + 5 + ) + bus3.publish(processed) + + # Bus 3: Processed + @bus3.subscribe(MockEventType.TEST_PROCESSED) + def bus3_processed(event): + event_data = event.data + cascade_log.append(f"bus3:processed:{event_data['result']}") + + # Start cascade + action = MockActionEvent(action="three_bus_test", value=10) + bus1.publish(action) + + # Verify three-bus cascade + assert len(cascade_log) == 3 + assert cascade_log[0] == "bus1:action:three_bus_test" + assert cascade_log[1] == "bus2:response:Bus1 processed three_bus_test" + assert cascade_log[2] == "bus3:processed:Bus2 completed Bus1 processed three_bus_test" + + +# ============================================================================ +# EVENT VISUALIZATION AND CASCADE ANALYSIS TESTS +# ============================================================================ + + +class TestEventVisualization: + """Test event visualization and analysis capabilities.""" + + def setup_method(self): + """Reset event system before each test.""" + set_event_system(EventSystem()) + + def test_event_history_tracking(self): + """Test tracking event history for visualization.""" + event_history = [] + + @subscribe(MockEventType.TEST_ACTION) + def track_action(event): + event_data = event.data + event_history.append({ + "type": "action", + "action": event_data["action"], + "value": event_data["value"], + "timestamp": time.time(), + }) + + @subscribe(MockEventType.TEST_RESPONSE) + def track_response(event): + event_data = event.data + event_history.append({ + "type": "response", + "response": event_data["response"], + "value": event_data["value"], + "timestamp": time.time(), + }) + + # Generate events + actions = ["login", "search", "logout"] + for action in actions: + event = MockActionEvent(action=action, value=len(action)) + publish(event) + + response = MockResponseEvent(response=f"Handled {action}", value=len(action) * 2) + publish(response) + + # Verify event history + assert len(event_history) == 6 # 3 actions + 3 responses + + # Check action events + action_events = [e for e in event_history if e["type"] == "action"] + assert len(action_events) == 3 + assert action_events[0]["action"] == "login" + assert action_events[1]["action"] == "search" + assert action_events[2]["action"] == "logout" + + # Check response events + response_events = [e for e in event_history if e["type"] == "response"] + assert len(response_events) == 3 + assert response_events[0]["response"] == "Handled login" + + def test_cascade_analysis(self): + """Test analyzing event cascades.""" + cascades = [] + current_cascade = [] + + @subscribe(MockEventType.TEST_ACTION) + def start_cascade(event): + nonlocal current_cascade + event_data = event.data + current_cascade = [f"action:{event_data['action']}"] + + @subscribe(MockEventType.TEST_RESPONSE) + def continue_cascade(event): + nonlocal current_cascade + event_data = event.data + current_cascade.append(f"response:{event_data['response']}") + + @subscribe(MockEventType.TEST_PROCESSED) + def end_cascade(event): + nonlocal current_cascade + event_data = event.data + current_cascade.append(f"processed:{event_data['result']}") + cascades.append(current_cascade.copy()) + + # Generate cascades + # Cascade 1: Simple + action1 = MockActionEvent(action="simple", value=1) + publish(action1) + + response1 = MockResponseEvent(response="Simple response", value=2) + publish(response1) + + processed1 = MockProcessedEvent(result="Simple completed", value=3) + publish(processed1) + + # Cascade 2: Complex + action2 = MockActionEvent(action="complex", value=10) + publish(action2) + + response2 = MockResponseEvent(response="Complex response", value=20) + publish(response2) + + processed2 = MockProcessedEvent(result="Complex completed", value=30) + publish(processed2) + + # Analyze cascades + assert len(cascades) == 2 + + # Verify cascade 1 + assert len(cascades[0]) == 3 + assert cascades[0][0] == "action:simple" + assert cascades[0][1] == "response:Simple response" + assert cascades[0][2] == "processed:Simple completed" + + # Verify cascade 2 + assert len(cascades[1]) == 3 + assert cascades[1][0] == "action:complex" + assert cascades[1][1] == "response:Complex response" + assert cascades[1][2] == "processed:Complex completed" + + # Cascade statistics + avg_length = sum(len(cascade) for cascade in cascades) / len(cascades) + assert avg_length == 3.0 + assert max(len(cascade) for cascade in cascades) == 3 + + +# ============================================================================ +# INTEGRATION TESTS +# ============================================================================ + + +class TestEventSystemIntegration: + """Test integration of all event system features.""" + + def setup_method(self): + """Reset event system before each test.""" + set_event_system(EventSystem()) + + def test_complete_workflow(self): + """Test a complete workflow using all features.""" + # Track everything + workflow_log = [] + event_history = [] + cascades = [] + current_cascade = [] + + # Event handlers + @subscribe(MockEventType.TEST_ACTION) + def handle_action(event): + event_data = event.data + workflow_log.append(f"action:{event_data['action']}") + event_history.append({"type": "action", "action": event_data["action"]}) + current_cascade.append(f"action:{event_data['action']}") + + # Auto-response + response = MockResponseEvent(response=f"Processed {event_data['action']}", value=event_data["value"] * 2) + publish(response) + + @subscribe(MockEventType.TEST_RESPONSE) + def handle_response(event): + event_data = event.data + workflow_log.append(f"response:{event_data['response']}") + event_history.append({"type": "response", "response": event_data["response"]}) + current_cascade.append(f"response:{event_data['response']}") + + # Auto-process + processed = MockProcessedEvent(result=f"Completed {event_data['response']}", value=event_data["value"] + 5) + publish(processed) + + @subscribe(MockEventType.TEST_PROCESSED) + def handle_processed(event): + event_data = event.data + workflow_log.append(f"processed:{event_data['result']}") + event_history.append({"type": "processed", "result": event_data["result"]}) + current_cascade.append(f"processed:{event_data['result']}") + cascades.append(current_cascade.copy()) + + # Execute workflow + action = MockActionEvent(action="integration_test", value=10) + publish(action) + + # Verify complete workflow + assert len(workflow_log) == 3 + assert workflow_log[0] == "action:integration_test" + assert workflow_log[1] == "response:Processed integration_test" + assert workflow_log[2] == "processed:Completed Processed integration_test" + + # Verify event history + assert len(event_history) == 3 + assert event_history[0]["type"] == "action" + assert event_history[1]["type"] == "response" + assert event_history[2]["type"] == "processed" + + # Verify cascade + assert len(cascades) == 1 + assert len(cascades[0]) == 3 + assert cascades[0][0] == "action:integration_test" + assert cascades[0][1] == "response:Processed integration_test" + assert cascades[0][2] == "processed:Completed Processed integration_test" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tox.ini b/tox.ini index a8944349..be78707d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] skipsdist = true -envlist = py311, py312, py313 +envlist = py312, py313 [gh-actions] python = - 3.11: py311 3.12: py312 3.13: py313 diff --git a/uv.lock b/uv.lock index 3fbb07df..dca8818e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.11, <4.0" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", -] +requires-python = ">=3.12, <4.0" [[package]] name = "aiohappyeyeballs" @@ -30,23 +26,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e6/0b/e39ad954107ebf213a2325038a3e7a506be3d98e1435e1f82086eec4cde2/aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2", size = 7822921, upload-time = "2025-07-10T13:05:33.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/e1/8029b29316971c5fa89cec170274582619a01b3d82dd1036872acc9bc7e8/aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597", size = 709960, upload-time = "2025-07-10T13:03:11.936Z" }, - { url = "https://files.pythonhosted.org/packages/96/bd/4f204cf1e282041f7b7e8155f846583b19149e0872752711d0da5e9cc023/aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393", size = 482235, upload-time = "2025-07-10T13:03:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/d6/0f/2a580fcdd113fe2197a3b9df30230c7e85bb10bf56f7915457c60e9addd9/aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179", size = 470501, upload-time = "2025-07-10T13:03:16.153Z" }, - { url = "https://files.pythonhosted.org/packages/38/78/2c1089f6adca90c3dd74915bafed6d6d8a87df5e3da74200f6b3a8b8906f/aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb", size = 1740696, upload-time = "2025-07-10T13:03:18.4Z" }, - { url = "https://files.pythonhosted.org/packages/4a/c8/ce6c7a34d9c589f007cfe064da2d943b3dee5aabc64eaecd21faf927ab11/aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245", size = 1689365, upload-time = "2025-07-10T13:03:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/18/10/431cd3d089de700756a56aa896faf3ea82bee39d22f89db7ddc957580308/aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b", size = 1788157, upload-time = "2025-07-10T13:03:22.44Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b2/26f4524184e0f7ba46671c512d4b03022633bcf7d32fa0c6f1ef49d55800/aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641", size = 1827203, upload-time = "2025-07-10T13:03:24.628Z" }, - { url = "https://files.pythonhosted.org/packages/e0/30/aadcdf71b510a718e3d98a7bfeaea2396ac847f218b7e8edb241b09bd99a/aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe", size = 1729664, upload-time = "2025-07-10T13:03:26.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/7f/7ccf11756ae498fdedc3d689a0c36ace8fc82f9d52d3517da24adf6e9a74/aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7", size = 1666741, upload-time = "2025-07-10T13:03:28.167Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4d/35ebc170b1856dd020c92376dbfe4297217625ef4004d56587024dc2289c/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635", size = 1715013, upload-time = "2025-07-10T13:03:30.018Z" }, - { url = "https://files.pythonhosted.org/packages/7b/24/46dc0380146f33e2e4aa088b92374b598f5bdcde1718c77e8d1a0094f1a4/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da", size = 1710172, upload-time = "2025-07-10T13:03:31.821Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0a/46599d7d19b64f4d0fe1b57bdf96a9a40b5c125f0ae0d8899bc22e91fdce/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419", size = 1690355, upload-time = "2025-07-10T13:03:34.754Z" }, - { url = "https://files.pythonhosted.org/packages/08/86/b21b682e33d5ca317ef96bd21294984f72379454e689d7da584df1512a19/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab", size = 1783958, upload-time = "2025-07-10T13:03:36.53Z" }, - { url = "https://files.pythonhosted.org/packages/4f/45/f639482530b1396c365f23c5e3b1ae51c9bc02ba2b2248ca0c855a730059/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0", size = 1804423, upload-time = "2025-07-10T13:03:38.504Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e5/39635a9e06eed1d73671bd4079a3caf9cf09a49df08490686f45a710b80e/aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28", size = 1717479, upload-time = "2025-07-10T13:03:40.158Z" }, - { url = "https://files.pythonhosted.org/packages/51/e1/7f1c77515d369b7419c5b501196526dad3e72800946c0099594c1f0c20b4/aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b", size = 427907, upload-time = "2025-07-10T13:03:41.801Z" }, - { url = "https://files.pythonhosted.org/packages/06/24/a6bf915c85b7a5b07beba3d42b3282936b51e4578b64a51e8e875643c276/aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced", size = 452334, upload-time = "2025-07-10T13:03:43.485Z" }, { url = "https://files.pythonhosted.org/packages/c3/0d/29026524e9336e33d9767a1e593ae2b24c2b8b09af7c2bd8193762f76b3e/aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22", size = 701055, upload-time = "2025-07-10T13:03:45.59Z" }, { url = "https://files.pythonhosted.org/packages/0a/b8/a5e8e583e6c8c1056f4b012b50a03c77a669c2e9bf012b7cf33d6bc4b141/aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a", size = 475670, upload-time = "2025-07-10T13:03:47.249Z" }, { url = "https://files.pythonhosted.org/packages/29/e8/5202890c9e81a4ec2c2808dd90ffe024952e72c061729e1d49917677952f/aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff", size = 468513, upload-time = "2025-07-10T13:03:49.377Z" }, @@ -207,10 +186,12 @@ wheels = [ [[package]] name = "bub" -version = "0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "any-llm-sdk", extra = ["anthropic", "aws", "azure", "google"] }, + { name = "eventure" }, + { name = "logfire" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "rich" }, @@ -233,6 +214,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "any-llm-sdk", extras = ["openai", "anthropic", "google", "azure", "aws"], specifier = ">=0.1.0" }, + { name = "eventure", specifier = ">=0.4.4" }, + { name = "logfire", specifier = ">=4.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "rich", specifier = ">=13.0.0" }, @@ -294,19 +277,6 @@ version = "3.4.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, @@ -393,6 +363,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "eventure" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/e0/fee440aac7c5099d5245638b49b5c0f4522a2d26e4d0fdab858bc9dd35df/eventure-0.4.4.tar.gz", hash = "sha256:36937e0ce03e7286e8b472065631bcd5d2a60e16423e4362452d93ec633d94f5", size = 58568, upload-time = "2025-03-16T12:24:49.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/a6/25806df1d4329f93d354ae55d32a5190ccbcf01b9468ae4442f093ab549a/eventure-0.4.4-py3-none-any.whl", hash = "sha256:e08688feafcd4031953b27015ae8b5039c0246d06649a4b477a13f52a14bc655", size = 17849, upload-time = "2025-03-16T12:24:47.711Z" }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -408,23 +396,6 @@ version = "1.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, - { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, - { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, - { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, - { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, - { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, - { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, - { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, @@ -524,6 +495,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/12/279afe7357af73f9737a3412b6f0bc1482075b896340eb46a2f9cb0fd791/google_genai-1.27.0-py3-none-any.whl", hash = "sha256:afd6b4efaf8ec1d20a6e6657d768b68d998d60007c6e220e9024e23c913c1833", size = 218489, upload-time = "2025-07-23T22:00:44.879Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "griffe" version = "1.7.3" @@ -591,6 +574,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -641,18 +636,6 @@ version = "0.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/dd/6cefc6bd68b1c3c979cecfa7029ab582b57690a31cd2f346c4d0ce7951b6/jiter-0.10.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3bebe0c558e19902c96e99217e0b8e8b17d570906e72ed8a87170bc290b1e978", size = 317473, upload-time = "2025-05-18T19:03:25.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/cf/fc33f5159ce132be1d8dd57251a1ec7a631c7df4bd11e1cd198308c6ae32/jiter-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:558cc7e44fd8e507a236bee6a02fa17199ba752874400a0ca6cd6e2196cdb7dc", size = 321971, upload-time = "2025-05-18T19:03:27.255Z" }, - { url = "https://files.pythonhosted.org/packages/68/a4/da3f150cf1d51f6c472616fb7650429c7ce053e0c962b41b68557fdf6379/jiter-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d613e4b379a07d7c8453c5712ce7014e86c6ac93d990a0b8e7377e18505e98d", size = 345574, upload-time = "2025-05-18T19:03:28.63Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/6e8d412e60ff06b186040e77da5f83bc158e9735759fcae65b37d681f28b/jiter-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f62cf8ba0618eda841b9bf61797f21c5ebd15a7a1e19daab76e4e4b498d515b2", size = 371028, upload-time = "2025-05-18T19:03:30.292Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d9/9ee86173aae4576c35a2f50ae930d2ccb4c4c236f6cb9353267aa1d626b7/jiter-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:919d139cdfa8ae8945112398511cb7fca58a77382617d279556b344867a37e61", size = 491083, upload-time = "2025-05-18T19:03:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2c/f955de55e74771493ac9e188b0f731524c6a995dffdcb8c255b89c6fb74b/jiter-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ddbc6ae311175a3b03bd8994881bc4635c923754932918e18da841632349db", size = 388821, upload-time = "2025-05-18T19:03:33.184Z" }, - { url = "https://files.pythonhosted.org/packages/81/5a/0e73541b6edd3f4aada586c24e50626c7815c561a7ba337d6a7eb0a915b4/jiter-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c440ea003ad10927a30521a9062ce10b5479592e8a70da27f21eeb457b4a9c5", size = 352174, upload-time = "2025-05-18T19:03:34.965Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c0/61eeec33b8c75b31cae42be14d44f9e6fe3ac15a4e58010256ac3abf3638/jiter-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc347c87944983481e138dea467c0551080c86b9d21de6ea9306efb12ca8f606", size = 391869, upload-time = "2025-05-18T19:03:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/41/22/5beb5ee4ad4ef7d86f5ea5b4509f680a20706c4a7659e74344777efb7739/jiter-0.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:13252b58c1f4d8c5b63ab103c03d909e8e1e7842d302473f482915d95fefd605", size = 523741, upload-time = "2025-05-18T19:03:38.168Z" }, - { url = "https://files.pythonhosted.org/packages/ea/10/768e8818538e5817c637b0df52e54366ec4cebc3346108a4457ea7a98f32/jiter-0.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d1bbf3c465de4a24ab12fb7766a0003f6f9bce48b8b6a886158c4d569452dc5", size = 514527, upload-time = "2025-05-18T19:03:39.577Z" }, - { url = "https://files.pythonhosted.org/packages/73/6d/29b7c2dc76ce93cbedabfd842fc9096d01a0550c52692dfc33d3cc889815/jiter-0.10.0-cp311-cp311-win32.whl", hash = "sha256:db16e4848b7e826edca4ccdd5b145939758dadf0dc06e7007ad0e9cfb5928ae7", size = 210765, upload-time = "2025-05-18T19:03:41.271Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/d394706deb4c660137caf13e33d05a031d734eb99c051142e039d8ceb794/jiter-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c9c1d5f10e18909e993f9641f12fe1c77b3e9b533ee94ffa970acc14ded3812", size = 209234, upload-time = "2025-05-18T19:03:42.918Z" }, { url = "https://files.pythonhosted.org/packages/6d/b5/348b3313c58f5fbfb2194eb4d07e46a35748ba6e5b3b3046143f3040bafa/jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b", size = 312262, upload-time = "2025-05-18T19:03:44.637Z" }, { url = "https://files.pythonhosted.org/packages/9c/4a/6a2397096162b21645162825f058d1709a02965606e537e3304b02742e9b/jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744", size = 320124, upload-time = "2025-05-18T19:03:46.341Z" }, { url = "https://files.pythonhosted.org/packages/2a/85/1ce02cade7516b726dd88f59a4ee46914bf79d1676d1228ef2002ed2f1c9/jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2", size = 345330, upload-time = "2025-05-18T19:03:47.596Z" }, @@ -704,6 +687,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] +[[package]] +name = "logfire" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/85/4ee1ced49f2c378fd7df9f507d6426da3c3520957bfe56e6c049ccacd4e4/logfire-4.0.0.tar.gz", hash = "sha256:64d95fbf0f05c99a8b4c99a35b5b2971f11adbfbe9a73726df11d01c12f9959c", size = 512056, upload-time = "2025-07-22T15:12:05.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/06/377ff0eb5d78ba893025eafed6104088eccefb0e538a9bed24e1f5d4fe53/logfire-4.0.0-py3-none-any.whl", hash = "sha256:4e50887d61954f849ec05343ca71b29fec5c0b6e4e945cabbceed664e37966e7", size = 211515, upload-time = "2025-07-22T15:12:02.113Z" }, +] + [[package]] name = "markdown" version = "3.8.2" @@ -731,16 +732,6 @@ version = "3.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, @@ -901,24 +892,6 @@ version = "6.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/f0/1a39863ced51f639c81a5463fbfa9eb4df59c20d1a8769ab9ef4ca57ae04/multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c", size = 76445, upload-time = "2025-06-30T15:51:24.01Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0e/a7cfa451c7b0365cd844e90b41e21fab32edaa1e42fc0c9f68461ce44ed7/multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df", size = 44610, upload-time = "2025-06-30T15:51:25.158Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bb/a14a4efc5ee748cc1904b0748be278c31b9295ce5f4d2ef66526f410b94d/multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d", size = 44267, upload-time = "2025-06-30T15:51:26.326Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f8/410677d563c2d55e063ef74fe578f9d53fe6b0a51649597a5861f83ffa15/multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539", size = 230004, upload-time = "2025-06-30T15:51:27.491Z" }, - { url = "https://files.pythonhosted.org/packages/fd/df/2b787f80059314a98e1ec6a4cc7576244986df3e56b3c755e6fc7c99e038/multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462", size = 247196, upload-time = "2025-06-30T15:51:28.762Z" }, - { url = "https://files.pythonhosted.org/packages/05/f2/f9117089151b9a8ab39f9019620d10d9718eec2ac89e7ca9d30f3ec78e96/multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9", size = 225337, upload-time = "2025-06-30T15:51:30.025Z" }, - { url = "https://files.pythonhosted.org/packages/93/2d/7115300ec5b699faa152c56799b089a53ed69e399c3c2d528251f0aeda1a/multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7", size = 257079, upload-time = "2025-06-30T15:51:31.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/ea/ff4bab367623e39c20d3b07637225c7688d79e4f3cc1f3b9f89867677f9a/multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9", size = 255461, upload-time = "2025-06-30T15:51:33.029Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/2c9246cda322dfe08be85f1b8739646f2c4c5113a1422d7a407763422ec4/multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821", size = 246611, upload-time = "2025-06-30T15:51:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/a8/62/279c13d584207d5697a752a66ffc9bb19355a95f7659140cb1b3cf82180e/multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d", size = 243102, upload-time = "2025-06-30T15:51:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/e06636f48c6d51e724a8bc8d9e1db5f136fe1df066d7cafe37ef4000f86a/multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6", size = 238693, upload-time = "2025-06-30T15:51:38.278Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/66c9d8fb9acf3b226cdd468ed009537ac65b520aebdc1703dd6908b19d33/multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430", size = 246582, upload-time = "2025-06-30T15:51:39.709Z" }, - { url = "https://files.pythonhosted.org/packages/cf/01/c69e0317be556e46257826d5449feb4e6aa0d18573e567a48a2c14156f1f/multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b", size = 253355, upload-time = "2025-06-30T15:51:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/c0/da/9cc1da0299762d20e626fe0042e71b5694f9f72d7d3f9678397cbaa71b2b/multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56", size = 247774, upload-time = "2025-06-30T15:51:42.291Z" }, - { url = "https://files.pythonhosted.org/packages/e6/91/b22756afec99cc31105ddd4a52f95ab32b1a4a58f4d417979c570c4a922e/multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183", size = 242275, upload-time = "2025-06-30T15:51:43.642Z" }, - { url = "https://files.pythonhosted.org/packages/be/f1/adcc185b878036a20399d5be5228f3cbe7f823d78985d101d425af35c800/multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5", size = 41290, upload-time = "2025-06-30T15:51:45.264Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d4/27652c1c6526ea6b4f5ddd397e93f4232ff5de42bea71d339bc6a6cc497f/multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2", size = 45942, upload-time = "2025-06-30T15:51:46.377Z" }, - { url = "https://files.pythonhosted.org/packages/16/18/23f4932019804e56d3c2413e237f866444b774b0263bcb81df2fdecaf593/multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb", size = 42880, upload-time = "2025-06-30T15:51:47.561Z" }, { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" }, { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" }, { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" }, @@ -987,12 +960,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, - { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, - { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, @@ -1045,6 +1012,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/1d/0432ea635097f4dbb34641a3650803d8a4aa29d06bafc66583bf1adcceb4/openai-1.95.1-py3-none-any.whl", hash = "sha256:8bbdfeceef231b1ddfabbc232b179d79f8b849aab5a7da131178f8d10e0f162f", size = 755613, upload-time = "2025-07-11T20:47:22.629Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/c9/4509bfca6bb43220ce7f863c9f791e0d5001c2ec2b5867d48586008b3d96/opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe", size = 64778, upload-time = "2025-07-11T12:23:28.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/5a/3f8d078dbf55d18442f6a2ecedf6786d81d7245844b2b20ce2b8ad6f0307/opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06", size = 65566, upload-time = "2025-07-11T12:23:07.944Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/d1/887f860529cba7fc3aba2f6a3597fefec010a17bd1b126810724707d9b51/opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb", size = 20299, upload-time = "2025-07-11T12:23:31.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/2c/e31dd3c719bff87fa77391eb7f38b1430d22868c52312cba8aad60f280e5/opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc", size = 18349, upload-time = "2025-07-11T12:23:11.713Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/7bdc06e84266a5b4b0fefd9790b3859804bf7682ce2daabcba2e22fdb3b2/opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79", size = 15908, upload-time = "2025-07-11T12:23:32.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/71/f118cd90dc26797077931dd598bde5e0cc652519db166593f962f8fcd022/opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d", size = 18589, upload-time = "2025-07-11T12:23:13.906Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.56b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/14/964e90f524655aed5c699190dad8dd9a05ed0f5fa334b4b33532237c2b51/opentelemetry_instrumentation-0.56b0.tar.gz", hash = "sha256:d2dbb3021188ca0ec8c5606349ee9a2919239627e8341d4d37f1d21ec3291d11", size = 28551, upload-time = "2025-07-11T12:26:19.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/aa/2328f27200b8e51640d4d7ff5343ba6a81ab7d2650a9f574db016aae4adf/opentelemetry_instrumentation-0.56b0-py3-none-any.whl", hash = "sha256:948967f7c8f5bdc6e43512ba74c9ae14acb48eb72a35b61afe8db9909f743be3", size = 31105, upload-time = "2025-07-11T12:25:22.788Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/a2/7366e32d9a2bccbb8614942dbea2cf93c209610385ea966cb050334f8df7/opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05", size = 46151, upload-time = "2025-07-11T12:23:38.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/a7/3f05de580da7e8a8b8dff041d3d07a20bf3bb62d3bcc027f8fd669a73ff4/opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809", size = 72536, upload-time = "2025-07-11T12:23:23.247Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/cf/1eb2ed2ce55e0a9aa95b3007f26f55c7943aeef0a783bb006bdd92b3299e/opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954", size = 160871, upload-time = "2025-07-11T12:23:39.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/4f/8e32b757ef3b660511b638ab52d1ed9259b666bdeeceba51a082ce3aea95/opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800", size = 119379, upload-time = "2025-07-11T12:23:24.521Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.56b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/8e/214fa817f63b9f068519463d8ab46afd5d03b98930c39394a37ae3e741d0/opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea", size = 124221, upload-time = "2025-07-11T12:23:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/3f/e80c1b017066a9d999efffe88d1cce66116dcf5cb7f80c41040a83b6e03b/opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2", size = 201625, upload-time = "2025-07-11T12:23:25.63Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1103,22 +1167,6 @@ version = "0.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, - { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, - { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, - { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, - { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, @@ -1170,6 +1218,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "6.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" }, + { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -1215,20 +1277,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, @@ -1260,15 +1308,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] [[package]] @@ -1362,15 +1401,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, @@ -1674,9 +1704,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, @@ -1701,17 +1728,6 @@ version = "15.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, @@ -1737,6 +1753,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, +] + [[package]] name = "yarl" version = "1.20.1" @@ -1748,23 +1806,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, - { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, - { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, - { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, - { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, @@ -1818,3 +1859,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]