diff --git a/tourist_scheduling_system/pyproject.toml b/tourist_scheduling_system/pyproject.toml index 2b50f82..3f3f443 100644 --- a/tourist_scheduling_system/pyproject.toml +++ b/tourist_scheduling_system/pyproject.toml @@ -32,11 +32,11 @@ dependencies = [ "httpx>=0.28.1", # Web framework "uvicorn>=0.40.0", - "fastapi>=0.115.0,<0.124.0", + "fastapi>=0.115.0,<0.129.0", "websockets>=11.0.0", # AI/LLM "openai>=2.15.0", - "google-adk>=1.22.1", + "google-adk==1.23.0", "litellm>=1.80.16", # Configuration "python-dotenv>=1.0.0", diff --git a/tourist_scheduling_system/src/agents/guide_agent.py b/tourist_scheduling_system/src/agents/guide_agent.py index eb52ca7..f4fe100 100644 --- a/tourist_scheduling_system/src/agents/guide_agent.py +++ b/tourist_scheduling_system/src/agents/guide_agent.py @@ -81,6 +81,7 @@ async def create_guide_agent( from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.remote_a2a_agent import RemoteA2aAgent from google.adk.models.lite_llm import LiteLlm + from google.adk.tools import load_memory transport_mode = get_transport_mode() logger.info(f"[Guide {guide_id}] Creating agent with transport mode: {transport_mode}") @@ -135,6 +136,7 @@ async def create_guide_agent( Be helpful and professional in describing your tour offerings.""", sub_agents=[scheduler_remote], + tools=[load_memory], ) return guide_agent @@ -162,7 +164,10 @@ async def run_guide_agent( max_group_size: Maximum tourists per tour """ # Import ADK runner at runtime - from google.adk.runners import InMemoryRunner + from google.adk.runners import Runner + from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService + from google.adk.sessions import DatabaseSessionService + from src.core.memory import FileMemoryService transport_mode = get_transport_mode() print(f"[Guide {guide_id}] Starting with ADK (transport: {transport_mode})...") @@ -205,7 +210,13 @@ async def run_guide_agent( # Create the guide agent agent = await create_guide_agent(guide_id, scheduler_url, a2a_client_factory) - runner = InMemoryRunner(agent=agent) + runner = Runner( + agent=agent, + app_name=f"guide_{guide_id}", + artifact_service=InMemoryArtifactService(), + session_service=DatabaseSessionService(db_url=f"sqlite+aiosqlite:///guide_sessions_{guide_id}.db"), + memory_service=FileMemoryService(f"guide_memory_{guide_id}.json") + ) # Create offer message message = create_guide_offer_message( diff --git a/tourist_scheduling_system/src/agents/scheduler_agent.py b/tourist_scheduling_system/src/agents/scheduler_agent.py index 572474d..e85e6c2 100644 --- a/tourist_scheduling_system/src/agents/scheduler_agent.py +++ b/tourist_scheduling_system/src/agents/scheduler_agent.py @@ -78,6 +78,7 @@ def get_scheduler_agent(): # Import ADK components at runtime from google.adk.agents.llm_agent import LlmAgent from google.adk.models.lite_llm import LiteLlm + from google.adk.tools import load_memory # Get model configuration from environment from core.model_factory import create_llm_model @@ -127,6 +128,7 @@ def get_scheduler_agent(): run_scheduling, get_schedule_status, clear_scheduler_state, + load_memory, ], ) @@ -157,18 +159,33 @@ def create_scheduler_app(host: str = "localhost", port: int = 10000): """ # Import ADK components at runtime from google.adk.a2a.utils.agent_to_a2a import to_a2a + from google.adk.runners import Runner + from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService + from google.adk.sessions import DatabaseSessionService + from src.core.memory import FileMemoryService # Load agent card from a2a_cards directory from src.core.a2a_cards import get_scheduler_card agent_card = get_scheduler_card(host=host, port=port) logger.info(f"[ADK Scheduler] Using agent card: {agent_card.name} v{agent_card.version}") + agent = get_scheduler_agent() + + runner = Runner( + agent=agent, + app_name="scheduler_agent", + artifact_service=InMemoryArtifactService(), + session_service=DatabaseSessionService(db_url="sqlite+aiosqlite:///scheduler_sessions.db"), + memory_service=FileMemoryService("scheduler_memory.json") + ) + return to_a2a( - get_scheduler_agent(), + agent, host=host, port=port, protocol="http", agent_card=agent_card, + runner=runner, ) @@ -199,7 +216,18 @@ def create_scheduler_a2a_components(host: str = "localhost", port: int = 10000): agent = get_scheduler_agent() # Create runner for the agent - runner = InMemoryRunner(agent=agent) + from google.adk.runners import Runner + from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService + from google.adk.sessions import DatabaseSessionService + from src.core.memory import FileMemoryService + + runner = Runner( + agent=agent, + app_name="scheduler_agent", + artifact_service=InMemoryArtifactService(), + session_service=DatabaseSessionService(db_url="sqlite+aiosqlite:///scheduler_sessions.db"), + memory_service=FileMemoryService("scheduler_memory.json") + ) # Create A2A executor wrapping the ADK runner agent_executor = A2aAgentExecutor(runner=runner) @@ -216,13 +244,22 @@ def create_scheduler_a2a_components(host: str = "localhost", port: int = 10000): async def run_console_demo(): """Run a console demo of the scheduler agent.""" # Import ADK runner at runtime - from google.adk.runners import InMemoryRunner + from google.adk.runners import Runner + from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService + from google.adk.sessions import DatabaseSessionService + from src.core.memory import FileMemoryService print("=" * 60) print("ADK Scheduler Agent - Console Demo") print("=" * 60) - runner = InMemoryRunner(agent=get_scheduler_agent()) + runner = Runner( + agent=get_scheduler_agent(), + app_name="scheduler_console_demo", + artifact_service=InMemoryArtifactService(), + session_service=DatabaseSessionService(db_url="sqlite+aiosqlite:///scheduler_demo_sessions.db"), + memory_service=FileMemoryService("scheduler_demo_memory.json") + ) # Demo messages demo_messages = [ diff --git a/tourist_scheduling_system/src/agents/tourist_agent.py b/tourist_scheduling_system/src/agents/tourist_agent.py index c96d8a0..1a920f6 100644 --- a/tourist_scheduling_system/src/agents/tourist_agent.py +++ b/tourist_scheduling_system/src/agents/tourist_agent.py @@ -74,6 +74,7 @@ async def create_tourist_agent( from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.remote_a2a_agent import RemoteA2aAgent from google.adk.models.lite_llm import LiteLlm + from google.adk.tools import load_memory transport_mode = get_transport_mode() logger.info(f"[Tourist {tourist_id}] Creating agent with transport mode: {transport_mode}") @@ -129,6 +130,7 @@ async def create_tourist_agent( After sending your request, you should receive a schedule proposal with matched guides. Be polite and clear in describing what kind of tour experience you're looking for.""", sub_agents=[scheduler_remote], + tools=[load_memory], ) return tourist_agent @@ -154,7 +156,10 @@ async def run_tourist_agent( budget: Maximum hourly budget in dollars """ # Import ADK runner at runtime - from google.adk.runners import InMemoryRunner + from google.adk.runners import Runner + from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService + from google.adk.sessions import DatabaseSessionService + from src.core.memory import FileMemoryService transport_mode = get_transport_mode() print(f"[Tourist {tourist_id}] Starting with ADK (transport: {transport_mode})...") @@ -197,7 +202,13 @@ async def run_tourist_agent( # Create the tourist agent agent = await create_tourist_agent(tourist_id, scheduler_url, a2a_client_factory) - runner = InMemoryRunner(agent=agent) + runner = Runner( + agent=agent, + app_name=f"tourist_{tourist_id}", + artifact_service=InMemoryArtifactService(), + session_service=DatabaseSessionService(db_url=f"sqlite+aiosqlite:///tourist_sessions_{tourist_id}.db"), + memory_service=FileMemoryService(f"tourist_memory_{tourist_id}.json") + ) # Create request message message = create_tourist_request_message( diff --git a/tourist_scheduling_system/src/core/memory.py b/tourist_scheduling_system/src/core/memory.py new file mode 100644 index 0000000..6c2f60a --- /dev/null +++ b/tourist_scheduling_system/src/core/memory.py @@ -0,0 +1,89 @@ +import json +import os +import threading +from typing import Any, Dict, List + +from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse +from google.adk.memory.memory_entry import MemoryEntry +from google.adk.sessions.session import Session +from google.adk.events.event import Event +from google.adk.memory import _utils + +class FileMemoryService(BaseMemoryService): + """A persistent file-based memory service.""" + + def __init__(self, file_path: str = "agent_memory.json"): + self.file_path = file_path + self._lock = threading.Lock() + self._ensure_file() + + def _ensure_file(self): + if not os.path.exists(self.file_path): + with open(self.file_path, 'w') as f: + json.dump({}, f) + + def _load_events(self) -> Dict[str, Dict[str, List[Dict]]]: + try: + with open(self.file_path, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def _save_events(self, data: Dict[str, Dict[str, List[Dict]]]): + with open(self.file_path, 'w') as f: + json.dump(data, f, indent=2) + + def _user_key(self, app_name: str, user_id: str): + return f'{app_name}/{user_id}' + + async def add_session_to_memory(self, session: Session): + user_key = self._user_key(session.app_name, session.user_id) + + # Serialize events + serialized_events = [] + for event in session.events: + if event.content and event.content.parts: + # Use model_dump(mode='json') for serialization friendly dict + serialized_events.append(event.model_dump(mode='json')) + + with self._lock: + data = self._load_events() + if user_key not in data: + data[user_key] = {} + data[user_key][session.id] = serialized_events + self._save_events(data) + + async def search_memory(self, *, app_name: str, user_id: str, query: str) -> SearchMemoryResponse: + user_key = self._user_key(app_name, user_id) + + with self._lock: + data = self._load_events() + user_sessions = data.get(user_key, {}) + + response = SearchMemoryResponse() + + # Simple keyword search (same as InMemoryMemoryService) + query_words = set(query.lower().split()) + + for session_id, events_data in user_sessions.items(): + for event_dict in events_data: + try: + event = Event.model_validate(event_dict) + except Exception: + continue + + if not event.content or not event.content.parts: + continue + + text = ' '.join([p.text for p in event.content.parts if p.text]) + text_lower = text.lower() + + if any(w in text_lower for w in query_words): + response.memories.append( + MemoryEntry( + content=event.content, + author=event.author, + timestamp=_utils.format_timestamp(event.timestamp) + ) + ) + return response diff --git a/tourist_scheduling_system/tests/test_agent_memory_config.py b/tourist_scheduling_system/tests/test_agent_memory_config.py new file mode 100644 index 0000000..2321ce9 --- /dev/null +++ b/tourist_scheduling_system/tests/test_agent_memory_config.py @@ -0,0 +1,113 @@ + +import pytest +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, AsyncMock + +# Check if ADK is available +try: + from google.adk.agents.llm_agent import LlmAgent + ADK_AVAILABLE = True +except ImportError: + ADK_AVAILABLE = False + +@pytest.mark.skipif(not ADK_AVAILABLE, reason="ADK not installed") +class TestAgentMemoryConfiguration: + + def test_scheduler_uses_file_memory(self): + from src.agents.scheduler_agent import create_scheduler_app + from src.core.memory import FileMemoryService + from google.adk.sessions import DatabaseSessionService + from google.adk.runners import Runner + + # Patch where it is defined since it is imported inside the function + with patch("google.adk.a2a.utils.agent_to_a2a.to_a2a") as mock_to_a2a: + create_scheduler_app() + + # Check if to_a2a was called + assert mock_to_a2a.called + + # Get the arguments passed to to_a2a + call_args = mock_to_a2a.call_args + kwargs = call_args.kwargs + + # Check if runner was passed + if "runner" in kwargs and kwargs["runner"] is not None: + runner = kwargs["runner"] + assert isinstance(runner.memory_service, FileMemoryService) + assert runner.memory_service.file_path == "scheduler_memory.json" + assert isinstance(runner.session_service, DatabaseSessionService) + else: + pytest.fail("runner argument not passed to to_a2a") + + @pytest.mark.asyncio + async def test_guide_uses_file_memory(self): + from src.agents.guide_agent import run_guide_agent + + # Mock dependencies + with patch("google.adk.runners.Runner") as MockRunner, \ + patch("src.core.memory.FileMemoryService") as MockFileMemoryService, \ + patch("google.adk.sessions.DatabaseSessionService") as MockDatabaseSessionService, \ + patch("src.agents.guide_agent.create_guide_agent", new_callable=AsyncMock) as mock_create_agent: + + mock_create_agent.return_value = MagicMock() + mock_runner_instance = MockRunner.return_value + mock_runner_instance.run_debug = AsyncMock(return_value=[]) + + await run_guide_agent( + guide_id="g1", + scheduler_url="http://localhost:8000", + categories="History", + available_start="2025-06-01T09:00:00", + available_end="2025-06-01T17:00:00", + hourly_rate=50.0 + ) + + # Check FileMemoryService usage + MockFileMemoryService.assert_called_once_with("guide_memory_g1.json") + + # Check DatabaseSessionService usage + MockDatabaseSessionService.assert_called_once_with(db_url="sqlite+aiosqlite:///guide_sessions_g1.db") + + # Check Runner usage + _, kwargs = MockRunner.call_args + assert "memory_service" in kwargs + assert kwargs["memory_service"] == MockFileMemoryService.return_value + assert "session_service" in kwargs + assert kwargs["session_service"] == MockDatabaseSessionService.return_value + + @pytest.mark.asyncio + async def test_tourist_uses_file_memory(self): + from src.agents.tourist_agent import run_tourist_agent + + # Mock dependencies + with patch("google.adk.runners.Runner") as MockRunner, \ + patch("src.core.memory.FileMemoryService") as MockFileMemoryService, \ + patch("google.adk.sessions.DatabaseSessionService") as MockDatabaseSessionService, \ + patch("src.agents.tourist_agent.create_tourist_agent", new_callable=AsyncMock) as mock_create_agent: + + mock_create_agent.return_value = MagicMock() + mock_runner_instance = MockRunner.return_value + mock_runner_instance.run_debug = AsyncMock(return_value=[]) + + await run_tourist_agent( + tourist_id="t1", + scheduler_url="http://localhost:8000", + availability_start="2025-06-01T09:00:00", + availability_end="2025-06-01T17:00:00", + preferences="History", + budget=100.0 + ) + + # Check FileMemoryService usage + MockFileMemoryService.assert_called_once_with("tourist_memory_t1.json") + + # Check DatabaseSessionService usage + MockDatabaseSessionService.assert_called_once_with(db_url="sqlite+aiosqlite:///tourist_sessions_t1.db") + + # Check Runner usage + _, kwargs = MockRunner.call_args + assert "memory_service" in kwargs + assert kwargs["memory_service"] == MockFileMemoryService.return_value + assert "session_service" in kwargs + assert kwargs["session_service"] == MockDatabaseSessionService.return_value diff --git a/tourist_scheduling_system/tests/test_dashboard.py b/tourist_scheduling_system/tests/test_dashboard.py new file mode 100644 index 0000000..0650f33 --- /dev/null +++ b/tourist_scheduling_system/tests/test_dashboard.py @@ -0,0 +1,334 @@ +import pytest +import json +import asyncio +from unittest.mock import MagicMock, patch, AsyncMock, Mock +from pathlib import Path +import sys + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect +from starlette.routing import WebSocketRoute + +from src.core import dashboard + +# Dummy state object to test set_dashboard_state +class DummyState: + def __init__(self): + self.tourist_requests = {} + self.guide_offers = {} + self.assignments = [] + self.metrics = {} + + def to_dict(self): + return { + "tourist_requests": self.tourist_requests, + "guide_offers": self.guide_offers, + "assignments": self.assignments, + "metrics": self.metrics + } + + def update_metrics(self): + pass + +class TestDashboardCore: + def setup_method(self): + # Reset globals + dashboard._HTML_TEMPLATE_CACHE = None + dashboard._ws_clients.clear() + dashboard._dashboard_state = None + dashboard._runner = None + dashboard._current_session_id = "genui_session" + + def test_load_html_template_file_exists(self, tmp_path): + dummy_template = tmp_path / "dashboard.html" + dummy_template.write_text("Hello World", encoding="utf-8") + + with patch("src.core.dashboard._HTML_TEMPLATE_PATH", dummy_template): + dashboard._HTML_TEMPLATE_CACHE = None + content = dashboard._load_html_template() + assert content == "Hello World" + # Second call should use cache + content = dashboard._load_html_template() + assert content == "Hello World" + + def test_load_html_template_file_missing(self, tmp_path): + dummy_template = tmp_path / "dash_missing.html" + assert not dummy_template.exists() + + with patch("src.core.dashboard._HTML_TEMPLATE_PATH", dummy_template): + dashboard._HTML_TEMPLATE_CACHE = None + content = dashboard._load_html_template() + assert "not found" in content + + def test_reload_html_template(self, tmp_path): + dummy_template = tmp_path / "dashboard.html" + dummy_template.write_text("V1", encoding="utf-8") + + with patch("src.core.dashboard._HTML_TEMPLATE_PATH", dummy_template): + dashboard._HTML_TEMPLATE_CACHE = None + assert dashboard._load_html_template() == "V1" + dummy_template.write_text("V2", encoding="utf-8") + assert dashboard._load_html_template() == "V1" + assert dashboard.reload_html_template() == "V2" + + def test_set_dashboard_state(self): + state = DummyState() + with patch("src.agents.ui_agent", create=True) as mock_agent: + # ensure _dashboard_state exists on mock + mock_agent._dashboard_state = None + dashboard.set_dashboard_state(state) + assert dashboard._dashboard_state == state + # Should also try to set variable on ui_agent module + # We can't strictly assert this because the mock here is a fresh one from patch, + # and dashboard imports it independently. But we trust the logic in dashboard.py + # or we could patch `sys.modules['src.agents.ui_agent']` + pass + + def test_set_transport_mode(self): + dashboard.set_transport_mode("slim") + assert dashboard._transport_mode == "slim" + + def test_reset_session(self): + mock_runner = MagicMock() + mock_session_service = MagicMock() + mock_runner.session_service = mock_session_service + + dashboard._runner = mock_runner + old_session = "old_session" + dashboard._current_session_id = old_session + + dashboard.reset_session() + + assert dashboard._current_session_id != old_session + assert "genui_session_" in dashboard._current_session_id + mock_session_service.create_session_sync.assert_called_once() + + def test_reset_session_no_runner(self): + dashboard._runner = None + # Should not raise + dashboard.reset_session() + + @pytest.mark.asyncio + async def test_broadcast_to_clients(self): + ws1 = AsyncMock() + ws2 = AsyncMock() + + dashboard._ws_clients.add(ws1) + dashboard._ws_clients.add(ws2) + + msg = {"type": "test"} + await dashboard.broadcast_to_clients(msg) + + expected_str = json.dumps(msg) + ws1.send_text.assert_called_with(expected_str) + ws2.send_text.assert_called_with(expected_str) + + # Test exception handling (disconnected client) + ws1.send_text.side_effect = Exception("Disconnected") + await dashboard.broadcast_to_clients(msg) + + assert ws1 not in dashboard._ws_clients + assert ws2 in dashboard._ws_clients + + +class TestDashboardApp: + def setup_method(self): + dashboard._HTML_TEMPLATE_CACHE = "Dashboard" + dashboard._ws_clients.clear() + dashboard._dashboard_state = None + dashboard._runner = None + self.app = dashboard.create_dashboard_app() + self.client = TestClient(self.app) + + def test_dashboard_endpoint(self): + response = self.client.get("/") + assert response.status_code == 200 + assert response.text == "Dashboard" + + def test_health_endpoint(self): + response = self.client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "agent": "adk_ui_dashboard"} + + def test_api_state_endpoint_no_state(self): + dashboard._dashboard_state = None + response = self.client.get("/api/state") + assert response.status_code == 200 + assert response.json() == {"error": "No state available"} + + def test_api_state_endpoint_with_state(self): + state = DummyState() + state.assignments.append({"id": 1}) + dashboard._dashboard_state = state + + response = self.client.get("/api/state") + assert response.status_code == 200 + data = response.json() + assert data["assignments"] == [{"id": 1}] + + def test_api_update_endpoint(self): + # Mock broadcast_to_clients + with patch("src.core.dashboard.broadcast_to_clients", new_callable=AsyncMock) as mock_broadcast: + # Setup initial state + state = DummyState() + state.assignments = [] + dashboard._dashboard_state = state + + update_data = { + "type": "assignment", + "tourist_id": "t1", + "guide_id": "g1", + "total_cost": 50 + } + + response = self.client.post("/api/update", json=update_data) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Verify state update + assert len(state.assignments) == 1 + assert state.assignments[0]["tourist_id"] == "t1" + + # Verify broadcast was called + mock_broadcast.assert_called() + call_args = mock_broadcast.call_args[0][0] + assert call_args["type"] == "assignment" + assert call_args["tourist_id"] == "t1" + def test_websocket_endpoint(self): + with self.client.websocket_connect("/ws") as websocket: + # Check initial state is received (empty or basic if state is None) + pass + + # Test with state + state = DummyState() + state.assignments.append({"id": 99}) + dashboard._dashboard_state = state + + with self.client.websocket_connect("/ws") as websocket: + # Should receive initial state + data = websocket.receive_json() + assert data["type"] == "initial_state" + assert data["data"]["assignments"] == [{"id": 99}] + + # Test ping/pong + websocket.send_text("ping") + response = websocket.receive_text() + assert response == "pong" + + @pytest.mark.asyncio + async def test_chat_endpoint_success(self): + mock_runner = MagicMock() + mock_runner.session_service.get_session_sync.return_value = None + + # Setup mocked event + async def mock_run_async(*args, **kwargs): + # Yield a text event + mock_event = MagicMock() + part = MagicMock() + part.text = "Hello User" + mock_event.content.parts = [part] + mock_event.error_message = None + yield mock_event + + mock_runner.run_async = mock_run_async + + with patch("src.core.dashboard.get_runner", return_value=mock_runner): + response = self.client.post("/api/chat", json={"message": "Hello"}) + assert response.status_code == 200 + data = response.json() + assert data["text"] == "Hello User" + assert "a2ui" in data + + @pytest.mark.asyncio + async def test_chat_endpoint_with_visualization(self): + mock_runner = MagicMock() + mock_runner.session_service.get_session_sync.return_value = None + + async def mock_run_async(*args, **kwargs): + mock_event = MagicMock() + part = MagicMock() + part.text = "Thinking..." + mock_event.content.parts = [part] + mock_event.error_message = None + yield mock_event + + mock_runner.run_async = mock_run_async + + # Setup dashboard state for visualization + state = DummyState() + state.assignments = [{"tourist_id": "T1", "guide_id": "G1", "window": {"start": "10:00", "end": "11:00"}}] + dashboard._dashboard_state = state + + with patch("src.core.dashboard.get_runner", return_value=mock_runner): + # Keyword "schedule" triggers visualization + response = self.client.post("/api/chat", json={"message": "Show me the schedule"}) + assert response.status_code == 200 + data = response.json() + # Check A2UI content + a2ui = data["a2ui"] + assert len(a2ui) > 0 + # Look for SchedulerCalendar + found_calendar = False + for msg in a2ui: + if "surfaceUpdate" in msg: + components = msg["surfaceUpdate"]["components"] + for comp in components: + if "SchedulerCalendar" in comp.get("component", {}): + found_calendar = True + assignments = comp["component"]["SchedulerCalendar"]["assignments"] + assert len(assignments) == 1 + assert assignments[0]["tourist_id"] == "T1" + assert found_calendar + + @pytest.mark.asyncio + async def test_chat_endpoint_reset_on_tool_error(self): + mock_runner = MagicMock() + # Mock run_async to raise exception + async def mock_raise(*args, **kwargs): + # This generator needs to raise immediately + if False: yield # make it a generator + raise Exception("tool_calls must be followed by tool messages") + + mock_runner.run_async = mock_raise + mock_runner.session_service.create_session_sync = MagicMock() + + with patch("src.core.dashboard.get_runner", return_value=mock_runner): + with patch("src.core.dashboard.reset_session") as mock_reset: + response = self.client.post("/api/chat", json={"message": "Fix tools"}) + assert response.status_code == 200 # Should return friendly error + assert "reset my memory" in response.json().get("text", "") + mock_reset.assert_called_once() + + @pytest.mark.asyncio + async def test_chat_endpoint_timeout(self): + mock_runner = MagicMock() + async def mock_raise(*args, **kwargs): + if False: yield + raise Exception("Timeout error") + + mock_runner.run_async = mock_raise + + with patch("src.core.dashboard.get_runner", return_value=mock_runner): + response = self.client.post("/api/chat", json={"message": "Hello"}) + assert response.status_code == 200 + assert "timed out" in response.json().get("text", "") + + def test_get_runner_singleton(self): + dashboard._runner = None + mock_ui_agent = MagicMock() + mock_im_runner_cls = MagicMock() + + with patch("src.agents.ui_agent.get_ui_agent", return_value=mock_ui_agent), \ + patch("src.core.dashboard.InMemoryRunner", mock_im_runner_cls): + + runner1 = dashboard.get_runner() + assert runner1 is not None + mock_im_runner_cls.assert_called_once() + + # Second call returns same instance + runner2 = dashboard.get_runner() + assert runner1 is runner2 + assert mock_im_runner_cls.call_count == 1 diff --git a/tourist_scheduling_system/tests/test_guide_cli.py b/tourist_scheduling_system/tests/test_guide_cli.py new file mode 100644 index 0000000..aacd107 --- /dev/null +++ b/tourist_scheduling_system/tests/test_guide_cli.py @@ -0,0 +1,165 @@ + +import pytest +from unittest.mock import MagicMock, patch, AsyncMock, ANY +import asyncio +from click.testing import CliRunner +import os + +from src.agents import guide_agent + +class TestGuideCLI: + + @pytest.fixture + def mock_dependencies(self): + """Mock external dependencies""" + # Create a mock coroutine for run_debug to avoid coroutine not awaited warning if needed + async def mock_run_debug(*args, **kwargs): + mock_event = MagicMock() + mock_event.content.parts = [MagicMock(text="Offer received")] + return [mock_event] + + with patch("google.adk.runners.Runner") as MockRunner, \ + patch("google.adk.agents.llm_agent.LlmAgent") as MockLlmAgent, \ + patch("src.core.model_factory.create_llm_model"), \ + patch("google.adk.agents.remote_a2a_agent.RemoteA2aAgent") as MockRemote, \ + patch("google.adk.artifacts.in_memory_artifact_service.InMemoryArtifactService"), \ + patch("google.adk.sessions.DatabaseSessionService"), \ + patch("src.core.memory.FileMemoryService"), \ + patch("src.agents.guide_agent.create_guide_agent", side_effect=guide_agent.create_guide_agent) as mock_create_agent: # We want to test logic inside create_guide + + # Setup Runner mock + mock_runner_instance = MagicMock() + mock_runner_instance.run_debug = AsyncMock(side_effect=mock_run_debug) + MockRunner.return_value = mock_runner_instance + + yield { + "MockRunner": MockRunner, + "MockRemote": MockRemote, + "mock_create_agent": mock_create_agent + } + + def test_create_guide_offer_message(self): + msg = guide_agent.create_guide_offer_message( + "g1", ["art"], "2023-01-01", "2023-01-02", 50.0 + ) + assert "g1" in msg + assert "art" in msg + assert "$50.0" in msg + + @pytest.mark.asyncio + async def test_run_guide_agent_http(self, mock_dependencies): + with patch.dict(os.environ, {"TRANSPORT_MODE": "http"}): + await guide_agent.run_guide_agent( + "g1", "http://localhost", ["art"], "start", "end", 50.0 + ) + + # Check if RemoteA2aAgent was initialized with URL card + mock_dependencies["MockRemote"].assert_called_once() + call_kwargs = mock_dependencies["MockRemote"].call_args.kwargs + assert "agent_card" in call_kwargs + assert isinstance(call_kwargs["agent_card"], str) + assert "http" in call_kwargs["agent_card"] + + # Check runner execution + mock_dependencies["MockRunner"].assert_called_once() + mock_dependencies["MockRunner"].return_value.run_debug.assert_awaited_once() + + @pytest.mark.asyncio + async def test_run_guide_agent_slim(self, mock_dependencies): + # Mock SLIM specific things - patching "core.slim_transport" because guide_agent imports "from core..." + # If imports are aliased, we need to be careful. + # We can try strict patching of the object if we can import it, but easier to try the path `core...` + + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim", "SCHEDULER_SLIM_TOPIC": "topic"}), \ + patch("core.slim_transport.create_slim_client_factory", new_callable=AsyncMock) as mock_iso_factory, \ + patch("core.slim_transport.config_from_env") as mock_config, \ + patch("core.slim_transport.minimal_slim_agent_card") as mock_card_helper, \ + patch("src.core.slim_transport.create_slim_client_factory", new_callable=AsyncMock) as mock_iso_factory_src, \ + patch("src.core.slim_transport.config_from_env") as mock_config_src, \ + patch("src.core.slim_transport.minimal_slim_agent_card") as mock_card_helper_src: + + # Setup both mocks just in case + mock_config.return_value = MagicMock(endpoint="slim://", local_id="id", shared_secret="s", tls_insecure=True) + mock_iso_factory.return_value = MagicMock() + + mock_config_src.return_value = MagicMock(endpoint="slim://", local_id="id", shared_secret="s", tls_insecure=True) + mock_iso_factory_src.return_value = MagicMock() + + await guide_agent.run_guide_agent( + "g1", "http://localhost", ["art"], "start", "end", 50.0 + ) + + # Check if either path was called + if mock_card_helper.called: + mock_card_helper.assert_called_with("topic") + mock_iso_factory.assert_awaited_once() + elif mock_card_helper_src.called: + mock_card_helper_src.assert_called_with("topic") + mock_iso_factory_src.assert_awaited_once() + else: + # If neither called, something is wrong + assert False, "Neither core nor src.core mocks were called" + + + @pytest.mark.asyncio + async def test_run_guide_agent_slim_import_error(self, mock_dependencies): + """Test fallback to HTTP when SLIM import fails.""" + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim"}), \ + patch("core.slim_transport.create_slim_client_factory", side_effect=ImportError("No SLIM")), \ + patch("src.core.slim_transport.create_slim_client_factory", side_effect=ImportError("No SLIM")): + + # Should not raise, just log error and fallback to http (which uses RemoteA2aAgent with URL) + await guide_agent.run_guide_agent( + "g1", "http://localhost", ["art"], "start", "end", 50.0 + ) + + # Verify fallback to HTTP transport + mock_dependencies["MockRemote"].assert_called() + # The agent card should be a string (URL) not an object + call_kwargs = mock_dependencies["MockRemote"].call_args.kwargs + assert isinstance(call_kwargs.get("agent_card"), str) + + @pytest.mark.asyncio + async def test_run_guide_agent_slim_generic_error(self, mock_dependencies): + """Test exception propagation when SLIM creation fails with generic error.""" + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim"}), \ + patch("core.slim_transport.create_slim_client_factory", side_effect=ValueError("Boom")), \ + patch("core.slim_transport.config_from_env"), \ + patch("src.core.slim_transport.create_slim_client_factory", side_effect=ValueError("Boom")), \ + patch("src.core.slim_transport.config_from_env"): + + with pytest.raises(ValueError, match="Boom"): + await guide_agent.run_guide_agent( + "g1", "http://localhost", ["art"], "start", "end", 50.0 + ) + + @pytest.mark.asyncio + async def test_run_guide_agent_retry_failure(self, mock_dependencies): + """Test retry logic failure.""" + mock_runner = mock_dependencies["MockRunner"].return_value + # Make run_debug raise exceptions + mock_runner.run_debug.side_effect = Exception("Connection failed") + + # Speed up retry delay + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with pytest.raises(Exception, match="Connection failed"): + await guide_agent.run_guide_agent( + "g1", "http://localhost", ["art"], "start", "end", 50.0 + ) + + # Should have retried multiple times (max_retries=30) + assert mock_runner.run_debug.call_count == 30 + assert mock_sleep.call_count == 29 + + def test_main(self, mock_dependencies): + runner = CliRunner() + with patch("sys.exit"), \ + patch("asyncio.run") as mock_run: + + result = runner.invoke(guide_agent.main, [ + "--guide-id", "g1", + "--categories", "art,history" + ]) + + assert result.exit_code == 0 + mock_run.assert_called_once() diff --git a/tourist_scheduling_system/tests/test_memory.py b/tourist_scheduling_system/tests/test_memory.py new file mode 100644 index 0000000..b3fa807 --- /dev/null +++ b/tourist_scheduling_system/tests/test_memory.py @@ -0,0 +1,146 @@ +import pytest +import os +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +# Add project root to path so we can import src +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.core.memory import FileMemoryService +from google.adk.sessions.session import Session +from google.adk.events.event import Event +from google.genai import types + +class TestFileMemoryService: + @pytest.fixture + def memory_file(self, tmp_path): + return str(tmp_path / "test_memory.json") + + @pytest.fixture + def memory_service(self, memory_file): + return FileMemoryService(file_path=memory_file) + + def test_init_creates_file(self, memory_file): + # File shouldn't exist yet (tmp_path exists, but file inside it doesn't) + assert not os.path.exists(memory_file) + FileMemoryService(file_path=memory_file) + assert os.path.exists(memory_file) + with open(memory_file, 'r') as f: + data = json.load(f) + assert data == {} + + def test_user_key(self, memory_service): + key = memory_service._user_key("app", "user") + assert key == "app/user" + + @pytest.mark.asyncio + async def test_add_session_and_search(self, memory_service): + # Create a dummy session with events + session = MagicMock(spec=Session) + session.app_name = "test_app" + session.user_id = "test_user" + session.id = "session_1" + + # Construct a real Event object + event_content = types.Content( + role="user", + parts=[types.Part(text="I love hiking in the mountains")] + ) + + event = Event( + author="user", + content=event_content + ) + + session.events = [event] + + await memory_service.add_session_to_memory(session) + + # Verify it was saved to file + with open(memory_service.file_path, 'r') as f: + data = json.load(f) + assert "test_app/test_user" in data + assert "session_1" in data["test_app/test_user"] + + # Test search success + response = await memory_service.search_memory( + app_name="test_app", + user_id="test_user", + query="hiking" + ) + assert len(response.memories) == 1 + assert "hiking" in response.memories[0].content.parts[0].text + + # Test search failure + response = await memory_service.search_memory( + app_name="test_app", + user_id="test_user", + query="swimming" + ) + assert len(response.memories) == 0 + + @pytest.mark.asyncio + async def test_search_memory_case_insensitive(self, memory_service): + # Create a dummy session with events + session = MagicMock(spec=Session) + session.app_name = "test_app" + session.user_id = "test_user" + session.id = "session_1" + + event_content = types.Content( + role="user", + parts=[types.Part(text="I love HIKING")] + ) + + event = Event( + author="user", + content=event_content + ) + + session.events = [event] + await memory_service.add_session_to_memory(session) + + response = await memory_service.search_memory( + app_name="test_app", + user_id="test_user", + query="hiking" + ) + assert len(response.memories) == 1 + + def test_load_events_corrupt_file(self, memory_service): + # Corrupt the file + with open(memory_service.file_path, 'w') as f: + f.write("invalid json") + + data = memory_service._load_events() + assert data == {} + + @pytest.mark.asyncio + async def test_search_memory_malformed_event(self, memory_service): + import json + + # Create a valid event with empty parts + event_content = types.Content(role="user", parts=[]) + event_empty_parts = Event( + author="user", + content=event_content, + timestamp=1704067200 + ).model_dump(mode='json') + + # Write directly to the file backend used by memory_service + data = { + "app/user": { + "sess1": [ + {"garbage": "data"}, + event_empty_parts + ] + } + } + with open(memory_service.file_path, 'w') as f: + json.dump(data, f) + + # Should not raise exception + results = await memory_service.search_memory(app_name="app", user_id="user", query="something") + assert len(results.memories) == 0 diff --git a/tourist_scheduling_system/tests/test_messages.py b/tourist_scheduling_system/tests/test_messages.py new file mode 100644 index 0000000..be6a84e --- /dev/null +++ b/tourist_scheduling_system/tests/test_messages.py @@ -0,0 +1,145 @@ + +import json +from datetime import datetime, timedelta +import pytest +from pydantic import ValidationError +from src.core.messages import ( + Window, + TouristRequest, + GuideOffer, + Assignment, + ScheduleProposal, +) + +class TestWindow: + def test_valid_window(self): + start = datetime.now() + end = start + timedelta(hours=1) + window = Window(start=start, end=end) + assert window.start == start + assert window.end == end + + def test_invalid_window_end_before_start(self): + start = datetime.now() + end = start - timedelta(hours=1) + with pytest.raises(ValidationError): + Window(start=start, end=end) + + def test_invalid_window_end_equal_start(self): + start = datetime.now() + with pytest.raises(ValidationError): + Window(start=start, end=start) + + def test_serialization(self): + start = datetime(2023, 1, 1, 10, 0, 0) + end = datetime(2023, 1, 1, 11, 0, 0) + window = Window(start=start, end=end) + + # to_dict + d = window.to_dict() + assert d["start"] == start.isoformat() + assert d["end"] == end.isoformat() + + # from_dict + window2 = Window.from_dict(d) + assert window2.start == start + assert window2.end == end + + # to_json + s = window.to_json() + assert isinstance(s, str) + + # from_json + window3 = Window.from_json(s) + assert window3.start == start + assert window3.end == end + + +class TestTouristRequest: + def test_serialization(self): + start = datetime(2023, 1, 1, 10, 0, 0) + end = datetime(2023, 1, 1, 11, 0, 0) + window = Window(start=start, end=end) + + req = TouristRequest( + tourist_id="tourist1", + availability=[window], + budget=100.0, + preferences=["art", "history"] + ) + + d = req.to_dict() + assert d["type"] == "TouristRequest" + assert d["tourist_id"] == "tourist1" + assert len(d["availability"]) == 1 + + req2 = TouristRequest.from_dict(d) + assert req2.tourist_id == "tourist1" + assert len(req2.availability) == 1 + assert req2.availability[0].start == start + + s = req.to_json() + req3 = TouristRequest.from_json(s) + assert req3.tourist_id == "tourist1" + + +class TestGuideOffer: + def test_serialization(self): + start = datetime(2023, 1, 1, 10, 0, 0) + end = datetime(2023, 1, 1, 11, 0, 0) + window = Window(start=start, end=end) + + offer = GuideOffer( + guide_id="guide1", + categories=["art"], + available_window=window, + hourly_rate=50.0, + max_group_size=5 + ) + + d = offer.to_dict() + assert d["type"] == "GuideOffer" + assert d["guide_id"] == "guide1" + assert d["available_window"]["start"] == start.isoformat() + + offer2 = GuideOffer.from_dict(d) + assert offer2.guide_id == "guide1" + assert offer2.available_window.start == start + + s = offer.to_json() + offer3 = GuideOffer.from_json(s) + assert offer3.guide_id == "guide1" + + +class TestScheduleProposal: + def test_serialization(self): + start = datetime(2023, 1, 1, 10, 0, 0) + end = datetime(2023, 1, 1, 11, 0, 0) + window = Window(start=start, end=end) + + assignment = Assignment( + tourist_id="tourist1", + guide_id="guide1", + time_window=window, + categories=["art"], + total_cost=50.0 + ) + + proposal = ScheduleProposal( + proposal_id="prop1", + assignments=[assignment] + ) + + d = proposal.to_dict() + assert d["type"] == "ScheduleProposal" + assert d["proposal_id"] == "prop1" + assert len(d["assignments"]) == 1 + + proposal2 = ScheduleProposal.from_dict(d) + assert proposal2.proposal_id == "prop1" + assert len(proposal2.assignments) == 1 + assert proposal2.assignments[0].tourist_id == "tourist1" + + s = proposal.to_json() + proposal3 = ScheduleProposal.from_json(s) + assert proposal3.proposal_id == "prop1" diff --git a/tourist_scheduling_system/tests/test_scheduler_cli.py b/tourist_scheduling_system/tests/test_scheduler_cli.py new file mode 100644 index 0000000..55a913a --- /dev/null +++ b/tourist_scheduling_system/tests/test_scheduler_cli.py @@ -0,0 +1,127 @@ + +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +from click.testing import CliRunner +import asyncio +import uvicorn + +# Import the module to be tested +from src.agents import scheduler_agent + +class TestSchedulerCLI: + + @pytest.fixture + def mock_dependencies(self): + """Mock external dependencies""" + # Create a mock coroutine for wait() + async def mock_wait(): + return True + + # Set return value for create_scheduler_a2a_components to return 2 items + mock_components_val = MagicMock() + mock_components_val.__iter__.return_value = [MagicMock(), MagicMock()] + # Or better, just set return value to a tuple + create_components_mock = patch("src.agents.scheduler_agent.create_scheduler_a2a_components").start() + create_components_mock.return_value = (MagicMock(), MagicMock()) + + with patch("src.agents.scheduler_agent.setup_tracing"), \ + patch("src.agents.scheduler_agent.setup_agent_logging"), \ + patch("src.agents.scheduler_agent.check_slim_available", return_value=True), \ + patch("uvicorn.run"), \ + patch("uvicorn.Server"), \ + patch("src.agents.scheduler_agent.create_scheduler_app"), \ + patch("src.agents.scheduler_agent.create_scheduler_a2a_components", return_value=(MagicMock(), MagicMock())) as mock_create_components, \ + patch("src.agents.scheduler_agent.run_console_demo", new_callable=AsyncMock) as mock_demo, \ + patch("src.agents.scheduler_agent.create_slim_server", new_callable=AsyncMock) as mock_create_slim: + + # Setup SLIM server mock + mock_server_instance = AsyncMock() + mock_server_instance.serve = AsyncMock() + mock_create_slim.return_value = AsyncMock(return_value=(mock_server_instance, MagicMock(), mock_wait())) + + yield { + "mock_demo": mock_demo, + "mock_create_slim": mock_create_slim, + "create_app": scheduler_agent.create_scheduler_app, + "create_components": mock_create_components, + "uvicorn_run": uvicorn.run + } + + def test_main_console_mode(self, mock_dependencies): + runner = CliRunner() + result = runner.invoke(scheduler_agent.main, ["--mode", "console"]) + assert result.exit_code == 0 + mock_dependencies["mock_demo"].assert_called_once() + + def test_main_http_mode(self, mock_dependencies): + runner = CliRunner() + # Default is console, so we need to override or check default which is http in logic but cli default is console + # The CLI default is actually "console". Logic says: if mode == "console" ... else: ... + + # Test HTTP path + with patch("uvicorn.run") as mock_run: + result = runner.invoke(scheduler_agent.main, ["--mode", "a2a", "--transport", "http"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + mock_dependencies["create_app"].assert_called_once() + + def test_main_slim_mode(self, mock_dependencies): + # We need to mock asyncio.run to avoid event loop issues if any, + # but the main function calls asyncio.run(run_slim_server()) + + # Since we mocked create_slim_server which returns an async function (start_server) + # We need to make sure the structure matches what main expects. + + # main expects: start_server = create_slim_server(...) + # then await start_server() returns (server, local_app, server_task) + + runner = CliRunner() + # Mock sys.exit to prevent actual exit if something goes wrong + with patch("sys.exit"): + # We need to patch asyncio.run because CliRunner and asyncio can conflict + # or just let it run if the mocks are async correct. + # Let's mock asyncio.run for the slim path + with patch("asyncio.run") as mock_async_run: + result = runner.invoke(scheduler_agent.main, ["--mode", "a2a", "--transport", "slim"]) + assert result.exit_code == 0 + mock_async_run.assert_called_once() + # create_scheduler_a2a_components should be called + mock_dependencies["create_components"].assert_called_once() + + +class TestSchedulerComponents: + + @pytest.mark.asyncio + async def test_run_console_demo(self): + # Mock everything needed for run_console_demo + with patch("google.adk.runners.Runner") as MockRunner, \ + patch("src.agents.scheduler_agent.get_scheduler_agent"), \ + patch("builtins.print"): + + mock_runner_instance = MagicMock() + MockRunner.return_value = mock_runner_instance + mock_runner_instance.run_debug = AsyncMock(return_value=[]) + + await scheduler_agent.run_console_demo() + + # Verify runner was created with specific services + MockRunner.assert_called_once() + kwargs = MockRunner.call_args.kwargs + assert "session_service" in kwargs + assert "memory_service" in kwargs + + def test_create_scheduler_a2a_components(self): + with patch("src.agents.scheduler_agent.get_scheduler_agent"), \ + patch("src.core.a2a_cards.get_scheduler_card") as mock_get_card, \ + patch("google.adk.runners.Runner") as MockRunner, \ + patch("google.adk.a2a.executor.a2a_agent_executor.A2aAgentExecutor"), \ + patch("a2a.server.request_handlers.DefaultRequestHandler") as MockHandler: + + mock_get_card.return_value = MagicMock(name="card", version="1.0") + + card, handler = scheduler_agent.create_scheduler_a2a_components() + + assert card is not None + assert handler is not None + MockRunner.assert_called_once() + MockHandler.assert_called_once() diff --git a/tourist_scheduling_system/tests/test_tourist_cli.py b/tourist_scheduling_system/tests/test_tourist_cli.py new file mode 100644 index 0000000..eeb396f --- /dev/null +++ b/tourist_scheduling_system/tests/test_tourist_cli.py @@ -0,0 +1,160 @@ + +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +import asyncio +from click.testing import CliRunner +import os + +from src.agents import tourist_agent + +class TestTouristCLI: + + @pytest.fixture + def mock_dependencies(self): + """Mock external dependencies""" + + async def mock_run_debug(*args, **kwargs): + mock_event = MagicMock() + mock_event.content.parts = [MagicMock(text="Request received")] + return [mock_event] + + with patch("google.adk.runners.Runner") as MockRunner, \ + patch("google.adk.agents.llm_agent.LlmAgent") as MockLlmAgent, \ + patch("src.core.model_factory.create_llm_model"), \ + patch("google.adk.agents.remote_a2a_agent.RemoteA2aAgent") as MockRemote, \ + patch("google.adk.artifacts.in_memory_artifact_service.InMemoryArtifactService"), \ + patch("google.adk.sessions.DatabaseSessionService"), \ + patch("src.core.memory.FileMemoryService"), \ + patch("src.agents.tourist_agent.create_tourist_agent", side_effect=tourist_agent.create_tourist_agent) as mock_create_agent: + + # Setup Runner mock + mock_runner_instance = MagicMock() + mock_runner_instance.run_debug = AsyncMock(side_effect=mock_run_debug) + MockRunner.return_value = mock_runner_instance + + yield { + "MockRunner": MockRunner, + "MockRemote": MockRemote, + "mock_create_agent": mock_create_agent + } + + def test_create_tourist_request_message(self): + msg = tourist_agent.create_tourist_request_message( + "t1", "2023-01-01", "2023-01-02", ["history"], 100.0 + ) + assert "t1" in msg + assert "history" in msg + assert "$100.0" in msg + + @pytest.mark.asyncio + async def test_run_tourist_agent_http(self, mock_dependencies): + with patch.dict(os.environ, {"TRANSPORT_MODE": "http"}): + await tourist_agent.run_tourist_agent( + "t1", "http://localhost", ["history"], "start", "end", 100.0 + ) + + # Check if RemoteA2aAgent was initialized with URL card + mock_dependencies["MockRemote"].assert_called_once() + call_kwargs = mock_dependencies["MockRemote"].call_args.kwargs + assert "agent_card" in call_kwargs + assert isinstance(call_kwargs["agent_card"], str) + assert "http" in call_kwargs["agent_card"] + + # Check runner execution - should be called twice in run_tourist_agent + mock_runner = mock_dependencies["MockRunner"].return_value + assert mock_runner.run_debug.call_count == 2 + mock_runner.run_debug.assert_awaited() + + @pytest.mark.asyncio + async def test_run_tourist_agent_slim(self, mock_dependencies): + # Mock SLIM specific things + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim", "SCHEDULER_SLIM_TOPIC": "topic"}), \ + patch("core.slim_transport.create_slim_client_factory", new_callable=AsyncMock) as mock_iso_factory, \ + patch("core.slim_transport.config_from_env") as mock_config, \ + patch("core.slim_transport.minimal_slim_agent_card") as mock_card_helper, \ + patch("src.core.slim_transport.create_slim_client_factory", new_callable=AsyncMock) as mock_iso_factory_src, \ + patch("src.core.slim_transport.config_from_env") as mock_config_src, \ + patch("src.core.slim_transport.minimal_slim_agent_card") as mock_card_helper_src: + + mock_config.return_value = MagicMock(endpoint="slim://", local_id="id", shared_secret="s", tls_insecure=True) + mock_iso_factory.return_value = MagicMock() + mock_config_src.return_value = MagicMock(endpoint="slim://", local_id="id", shared_secret="s", tls_insecure=True) + mock_iso_factory_src.return_value = MagicMock() + + await tourist_agent.run_tourist_agent( + "t1", "http://localhost", ["history"], "start", "end", 100.0 + ) + + # Check if one of them was called + if mock_card_helper.called: + mock_card_helper.assert_called_with("topic") + mock_iso_factory.assert_awaited_once() + elif mock_card_helper_src.called: + mock_card_helper_src.assert_called_with("topic") + mock_iso_factory_src.assert_awaited_once() + else: + assert False, "Neither core nor src.core mocks were called" + + # Check logic passed a2a_client_factory + mock_dependencies["MockRemote"].assert_called_once() + call_kwargs = mock_dependencies["MockRemote"].call_args.kwargs + assert "a2a_client_factory" in call_kwargs + assert call_kwargs["a2a_client_factory"] is not None + + @pytest.mark.asyncio + async def test_run_tourist_agent_slim_import_error(self, mock_dependencies): + """Test fallback to HTTP when SLIM import fails.""" + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim"}), \ + patch("core.slim_transport.create_slim_client_factory", side_effect=ImportError("No SLIM")), \ + patch("src.core.slim_transport.create_slim_client_factory", side_effect=ImportError("No SLIM")): + + await tourist_agent.run_tourist_agent( + "t1", "http://localhost", ["history"], "start", "end", 100.0 + ) + + # Verify fallback to HTTP transport + mock_dependencies["MockRemote"].assert_called() + call_kwargs = mock_dependencies["MockRemote"].call_args.kwargs + assert isinstance(call_kwargs.get("agent_card"), str) + + @pytest.mark.asyncio + async def test_run_tourist_agent_slim_generic_error(self, mock_dependencies): + """Test exception propagation when SLIM creation fails.""" + with patch.dict(os.environ, {"TRANSPORT_MODE": "slim"}), \ + patch("core.slim_transport.create_slim_client_factory", side_effect=ValueError("Boom")), \ + patch("core.slim_transport.config_from_env"), \ + patch("src.core.slim_transport.create_slim_client_factory", side_effect=ValueError("Boom")), \ + patch("src.core.slim_transport.config_from_env"): + + with pytest.raises(ValueError, match="Boom"): + await tourist_agent.run_tourist_agent( + "t1", "http://localhost", ["history"], "start", "end", 100.0 + ) + + @pytest.mark.asyncio + async def test_run_tourist_agent_retry_failure(self, mock_dependencies): + """Test retry logic failure.""" + mock_runner = mock_dependencies["MockRunner"].return_value + mock_runner.run_debug.side_effect = Exception("Connection failed") + + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with pytest.raises(Exception, match="Connection failed"): + await tourist_agent.run_tourist_agent( + "t1", "http://localhost", ["history"], "start", "end", 100.0 + ) + + assert mock_runner.run_debug.call_count == 30 + assert mock_sleep.call_count == 29 + + def test_main(self, mock_dependencies): + runner = CliRunner() + with patch("sys.exit"), \ + patch("asyncio.run") as mock_run: + + result = runner.invoke(tourist_agent.main, [ + "--tourist-id", "t1", + "--preferences", "art,history" + ]) + + assert result.exit_code == 0 + mock_run.assert_called_once() diff --git a/tourist_scheduling_system/uv.lock b/tourist_scheduling_system/uv.lock index e332680..93a8d3c 100644 --- a/tourist_scheduling_system/uv.lock +++ b/tourist_scheduling_system/uv.lock @@ -232,14 +232,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, + { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, ] [[package]] @@ -797,11 +797,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.1.0" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.135.0" +version = "1.136.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -946,9 +946,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/84/908cf03a1316c668766e538a210c5caaf2161ef638a7428aa47aee2a890e/google_cloud_aiplatform-1.135.0.tar.gz", hash = "sha256:1e42fc4c38147066ad05d93cb9208201514d359fb2a64663333cea2d1ec9ab42", size = 9941458, upload-time = "2026-01-28T00:25:48.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/9c/38ce04e3ef89034c736320a27b4a6e3171ca2f3fb56d38f76a310c745d14/google_cloud_aiplatform-1.136.0.tar.gz", hash = "sha256:01e64a0d0861486e842bf7e904077c847bcc1b654a29883509d57476de915b7d", size = 9946722, upload-time = "2026-02-04T16:28:12.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/66/d81fb4b81db3ee2f00f8b391f91cdb0e01d6886a2b78105f5d9b6c376104/google_cloud_aiplatform-1.135.0-py2.py3-none-any.whl", hash = "sha256:32b53ee61b3f51b14e21dc98fa9d9021c5db171cf7a407bd71abd3da46f5a6a4", size = 8200215, upload-time = "2026-01-28T00:25:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/55/e8/f317dc96c9c73846dd3e4d16691cc5f248801f46354d9d57f2c67fd67413/google_cloud_aiplatform-1.136.0-py2.py3-none-any.whl", hash = "sha256:5c829f002b7b673dcd0e718f55cc0557b571bd10eb5cdb7882d72916cfbf8c0e", size = 8203924, upload-time = "2026-02-04T16:28:10.343Z" }, ] [package.optional-dependencies] @@ -1114,7 +1114,7 @@ wheels = [ [[package]] name = "google-cloud-monitoring" -version = "2.29.0" +version = "2.29.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1123,14 +1123,14 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/a1/a1a0c678569f2a7b1fa65ef71ff528650231a298fc2b89ad49c9991eab94/google_cloud_monitoring-2.29.0.tar.gz", hash = "sha256:eedb8afd1c4e80e8c62435f05c448e9e65be907250a66d81e6af5909778267b6", size = 404769, upload-time = "2026-01-15T13:04:01.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/06/9fc0a34bed4221a68eef3e0373ae054de367dc42c0b689d5d917587ef61b/google_cloud_monitoring-2.29.1.tar.gz", hash = "sha256:86cac55cdd2608561819d19544fb3c129bbb7dcecc445d8de426e34cd6fa8e49", size = 404383, upload-time = "2026-02-05T18:59:13.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/63/b1f6e86ddde8548a0cade2edf3c8ec2183e57f002ea4301b3890a6717190/google_cloud_monitoring-2.29.0-py3-none-any.whl", hash = "sha256:93aa264da0f57f3de2900b0250a37ca27068984f6d94e54175d27aea12a4637f", size = 387988, upload-time = "2026-01-15T13:03:23.528Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/7c27aa95eccf8b62b066295a7c4ad04284364b696d3e7d9d47152b255a24/google_cloud_monitoring-2.29.1-py3-none-any.whl", hash = "sha256:944a57031f20da38617d184d5658c1f938e019e8061f27fd944584831a1b9d5a", size = 387922, upload-time = "2026-02-05T18:58:54.964Z" }, ] [[package]] name = "google-cloud-pubsub" -version = "2.34.0" +version = "2.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1143,9 +1143,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/b0/7073a2d17074f0d4a53038c6141115db19f310a2f96bd3911690f15bd701/google_cloud_pubsub-2.34.0.tar.gz", hash = "sha256:25f98c3ba16a69871f9ebbad7aece3fe63c8afe7ba392aad2094be730d545976", size = 396526, upload-time = "2025-12-16T22:44:22.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ad/dde4c0b014247190a4df0dfa9c90de81b47909e22e2e442198f449a3593f/google_cloud_pubsub-2.35.0.tar.gz", hash = "sha256:2c0d1d7ccda52fa12fb73f34b7eb9899381e2fd931c7d47b10f724cdfac06f95", size = 396812, upload-time = "2026-02-05T22:29:14.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/d3/9c06e5ccd3e5b0f4b3bc6d223cb21556e597571797851e9f8cc38b7e2c0b/google_cloud_pubsub-2.34.0-py3-none-any.whl", hash = "sha256:aa11b2471c6d509058b42a103ed1b3643f01048311a34fd38501a16663267206", size = 320110, upload-time = "2025-12-16T22:44:20.349Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/b783f4e910f0ec4010d279bafce0cd1ed8a10bac41970eb5c6a6416008ab/google_cloud_pubsub-2.35.0-py3-none-any.whl", hash = "sha256:c32e4eb29e532ec784b5abb5d674807715ec07895b7c022b9404871dec09970d", size = 320973, upload-time = "2026-02-05T22:29:13.096Z" }, ] [[package]] @@ -1208,7 +1208,7 @@ wheels = [ [[package]] name = "google-cloud-speech" -version = "2.36.0" +version = "2.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1217,9 +1217,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/01/0bfe56e1f935285ac21908ddfb5574b09bf5829ad3441649e1403f9f1b34/google_cloud_speech-2.36.0.tar.gz", hash = "sha256:3a445a033cc7772f7d073c03142a7e80048415db42981372c6b81edc76a1e27a", size = 401922, upload-time = "2026-01-15T13:04:52.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/b7/b078693abc67af4cbbf60727ebd29d37f786ada8a6146ada2d5918da6a3a/google_cloud_speech-2.36.1.tar.gz", hash = "sha256:30fef3b30c1e1b5f376be3cf82a724c8629994de045935f85e4b7bceae8c2129", size = 401910, upload-time = "2026-02-05T18:59:22.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/0e/29e5d7bfe636e7b7b2647d6f4d50ca07b84149ba9adba86c6be219ac8480/google_cloud_speech-2.36.0-py3-none-any.whl", hash = "sha256:bdd0047fe2961d42307bdb7393fe5c7c1491290b2e6d66bfc6e2b7bcdfb8794e", size = 342111, upload-time = "2026-01-15T13:02:59.482Z" }, + { url = "https://files.pythonhosted.org/packages/f0/13/b1437f2716ce56ca13298855929e5fb790c13c3ddee24248a3682ba392a5/google_cloud_speech-2.36.1-py3-none-any.whl", hash = "sha256:a54985b3e7c001a9feae78cec77e67e85d29b3851d00af1f805ffff3f477d8fe", size = 342457, upload-time = "2026-02-05T18:58:59.518Z" }, ] [[package]] @@ -1287,7 +1287,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.61.0" +version = "1.62.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1301,9 +1301,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/38/421cd7e70952a536be87a0249409f87297d84f523754a25b08fe94b97e7f/google_genai-1.61.0.tar.gz", hash = "sha256:5773a4e8ad5b2ebcd54a633a67d8e9c4f413032fef07977ee47ffa34a6d3bbdf", size = 489672, upload-time = "2026-01-30T20:50:27.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/4c/71b32b5c8db420cf2fd0d5ef8a672adbde97d85e5d44a0b4fca712264ef1/google_genai-1.62.0.tar.gz", hash = "sha256:709468a14c739a080bc240a4f3191df597bf64485b1ca3728e0fb67517774c18", size = 490888, upload-time = "2026-02-04T22:48:41.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/87/78dd70cb59f7acf3350f53c5144a7aa7bc39c6f425cd7dc1224b59fcdac3/google_genai-1.61.0-py3-none-any.whl", hash = "sha256:cb073ef8287581476c1c3f4d8e735426ee34478e500a56deef218fa93071e3ca", size = 721948, upload-time = "2026-01-30T20:50:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/09/5f/4645d8a28c6e431d0dd6011003a852563f3da7037d36af53154925b099fd/google_genai-1.62.0-py3-none-any.whl", hash = "sha256:4c3daeff3d05fafee4b9a1a31f9c07f01bc22051081aa58b4d61f58d16d1bcc0", size = 724166, upload-time = "2026-02-04T22:48:39.956Z" }, ] [[package]] @@ -1411,67 +1411,67 @@ wheels = [ [[package]] name = "grpcio" -version = "1.76.0" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, - { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, - { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, - { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, - { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, - { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, ] [[package]] name = "grpcio-status" -version = "1.76.0" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, ] [[package]] @@ -1556,7 +1556,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.3.7" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -1570,9 +1570,9 @@ dependencies = [ { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/3f/352efd52136bfd8aa9280c6d4a445869226ae2ccd49ddad4f62e90cfd168/huggingface_hub-1.3.7.tar.gz", hash = "sha256:5f86cd48f27131cdbf2882699cbdf7a67dd4cbe89a81edfdc31211f42e4a5fd1", size = 627537, upload-time = "2026-02-02T10:40:10.61Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/89/bfbfde252d649fae8d5f09b14a2870e5672ed160c1a6629301b3e5302621/huggingface_hub-1.3.7-py3-none-any.whl", hash = "sha256:8155ce937038fa3d0cb4347d752708079bc85e6d9eb441afb44c84bcf48620d2", size = 536728, upload-time = "2026-02-02T10:40:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, ] [[package]] @@ -1778,7 +1778,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.7" +version = "1.81.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1794,9 +1794,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/69/cfa8a1d68cd10223a9d9741c411e131aece85c60c29c1102d762738b3e5c/litellm-1.81.7.tar.gz", hash = "sha256:442ff38708383ebee21357b3d936e58938172bae892f03bc5be4019ed4ff4a17", size = 14039864, upload-time = "2026-02-03T19:43:10.633Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/8f/2a08f3d86fd008b4b02254649883032068378a8551baed93e8d9dcbbdb5d/litellm-1.81.9.tar.gz", hash = "sha256:a2cd9bc53a88696c21309ef37c55556f03c501392ed59d7f4250f9932917c13c", size = 16276983, upload-time = "2026-02-07T21:14:24.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/95/8cecc7e6377171e4ac96f23d65236af8706d99c1b7b71a94c72206672810/litellm-1.81.7-py3-none-any.whl", hash = "sha256:58466c88c3289c6a3830d88768cf8f307581d9e6c87861de874d1128bb2de90d", size = 12254178, upload-time = "2026-02-03T19:43:08.035Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/672fc06c8a2803477e61e0de383d3c6e686e0f0fc62789c21f0317494076/litellm-1.81.9-py3-none-any.whl", hash = "sha256:24ee273bc8a62299fbb754035f83fb7d8d44329c383701a2bd034f4fd1c19084", size = 14433170, upload-time = "2026-02-07T21:14:21.469Z" }, ] [[package]] @@ -2032,9 +2032,9 @@ requires-dist = [ { name = "agntcy-dir", specifier = ">=0.6.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "click", specifier = ">=8.0.0" }, - { name = "fastapi", specifier = ">=0.115.0,<0.124.0" }, + { name = "fastapi", specifier = ">=0.115.0,<0.129.0" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0.0" }, - { name = "google-adk", specifier = ">=1.22.1" }, + { name = "google-adk", specifier = "==1.23.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "litellm", specifier = ">=1.80.16" }, @@ -2214,7 +2214,7 @@ wheels = [ [[package]] name = "openai" -version = "2.16.0" +version = "2.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2226,9 +2226,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" }, ] [[package]] @@ -3387,11 +3387,11 @@ wheels = [ [[package]] name = "tenacity" -version = "9.1.2" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]]