diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 8afe0ca65..1bdc65431 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -35,6 +35,7 @@ cls coc codegen coro +culsans datamodel deepwiki drivername @@ -127,7 +128,7 @@ taskupdate testuuid Tful tiangolo +TResponse typ typeerror vulnz -TResponse diff --git a/pyproject.toml b/pyproject.toml index 964a0bac4..99b92360f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "google-api-core>=1.26.0", "json-rpc>=1.15.0", "googleapis-common-protos>=1.70.0", + "culsans>=0.11.0 ; python_full_version < '3.13'", ] classifiers = [ diff --git a/src/a2a/server/events/event_consumer.py b/src/a2a/server/events/event_consumer.py index 0449a7fbd..f21ab87a5 100644 --- a/src/a2a/server/events/event_consumer.py +++ b/src/a2a/server/events/event_consumer.py @@ -1,12 +1,11 @@ import asyncio import logging -import sys from collections.abc import AsyncGenerator from pydantic import ValidationError -from a2a.server.events.event_queue import Event, EventQueue +from a2a.server.events.event_queue import Event, EventQueue, QueueShutDown from a2a.types.a2a_pb2 import ( Message, Task, @@ -17,13 +16,6 @@ from a2a.utils.telemetry import SpanKind, trace_class -# This is an alias to the exception for closed queue -QueueClosed: type[Exception] = asyncio.QueueEmpty - -# When using python 3.13 or higher, the closed queue signal is QueueShutdown -if sys.version_info >= (3, 13): - QueueClosed = asyncio.QueueShutDown - logger = logging.getLogger(__name__) @@ -130,7 +122,7 @@ async def consume_all(self) -> AsyncGenerator[Event]: except asyncio.TimeoutError: # pyright: ignore [reportUnusedExcept] # This class was made an alias of built-in TimeoutError after 3.11 continue - except (QueueClosed, asyncio.QueueEmpty): + except (QueueShutDown, asyncio.QueueEmpty): # Confirm that the queue is closed, e.g. we aren't on # python 3.12 and get a queue empty error on an open queue if self.queue.is_closed(): diff --git a/src/a2a/server/events/event_queue.py b/src/a2a/server/events/event_queue.py index d0099f4b2..73068445a 100644 --- a/src/a2a/server/events/event_queue.py +++ b/src/a2a/server/events/event_queue.py @@ -3,9 +3,31 @@ import sys from types import TracebackType +from typing import Any from typing_extensions import Self + +if sys.version_info >= (3, 13): + from asyncio import Queue as AsyncQueue + from asyncio import QueueShutDown + + def _create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]: + """Create a backwards-compatible queue object.""" + return AsyncQueue(maxsize=maxsize) +else: + import culsans + + from culsans import AsyncQueue # type: ignore[no-redef] + from culsans import ( + AsyncQueueShutDown as QueueShutDown, # type: ignore[no-redef] + ) + + def _create_async_queue(maxsize: int = 0) -> AsyncQueue[Any]: + """Create a backwards-compatible queue object.""" + return culsans.Queue(maxsize=maxsize).async_q # type: ignore[no-any-return] + + from a2a.types.a2a_pb2 import ( Message, Task, @@ -41,7 +63,9 @@ def __init__(self, max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE) -> None: if max_queue_size <= 0: raise ValueError('max_queue_size must be greater than 0') - self.queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=max_queue_size) + self.queue: AsyncQueue[Event] = _create_async_queue( + maxsize=max_queue_size + ) self._children: list[EventQueue] = [] self._is_closed = False self._lock = asyncio.Lock() @@ -73,8 +97,12 @@ async def enqueue_event(self, event: Event) -> None: logger.debug('Enqueuing event of type: %s', type(event)) - # Make sure to use put instead of put_nowait to avoid blocking the event loop. - await self.queue.put(event) + try: + await self.queue.put(event) + except QueueShutDown: + logger.warning('Queue was closed during enqueuing. Event dropped.') + return + for child in self._children: await child.enqueue_event(event) @@ -107,14 +135,9 @@ async def dequeue_event(self, no_wait: bool = False) -> Event: asyncio.QueueShutDown: If the queue has been closed and is empty. """ async with self._lock: - if ( - sys.version_info < (3, 13) - and self._is_closed - and self.queue.empty() - ): - # On 3.13+, skip early raise; await self.queue.get() will raise QueueShutDown after shutdown() + if self._is_closed and self.queue.empty(): logger.warning('Queue is closed. Event will not be dequeued.') - raise asyncio.QueueEmpty('Queue is closed.') + raise QueueShutDown('Queue is closed.') if no_wait: logger.debug('Attempting to dequeue event (no_wait=True).') @@ -152,56 +175,26 @@ def tap(self) -> 'EventQueue': async def close(self, immediate: bool = False) -> None: """Closes the queue for future push events and also closes all child queues. - Once closed, no new events can be enqueued. Behavior is consistent across - Python versions: - - Python >= 3.13: Uses `asyncio.Queue.shutdown` to stop the queue. With - `immediate=True` the queue is shut down and pending events are cleared; with - `immediate=False` the queue is shut down and we wait for it to drain via - `queue.join()`. - - Python < 3.13: Emulates the same semantics by clearing on `immediate=True` - or awaiting `queue.join()` on `immediate=False`. - - Consumers attempting to dequeue after close on an empty queue will observe - `asyncio.QueueShutDown` on Python >= 3.13 and `asyncio.QueueEmpty` on - Python < 3.13. - Args: - immediate (bool): - - True: Immediately closes the queue and clears all unprocessed events without waiting for them to be consumed. This is suitable for scenarios where you need to forcefully interrupt and quickly release resources. - - False (default): Gracefully closes the queue, waiting for all queued events to be processed (i.e., the queue is drained) before closing. This is suitable when you want to ensure all events are handled. - + immediate: If True, immediately flushes the queue, discarding all pending + events, and causes any currently blocked `dequeue_event` calls to raise + `QueueShutDown`. If False (default), the queue is marked as closed to new + events, but existing events can still be dequeued and processed until the + queue is fully drained. """ logger.debug('Closing EventQueue.') async with self._lock: - # If already closed, just return. if self._is_closed and not immediate: return - if not self._is_closed: - self._is_closed = True - # If using python 3.13 or higher, use shutdown but match <3.13 semantics - if sys.version_info >= (3, 13): - if immediate: - # Immediate: stop queue and clear any pending events, then close children - self.queue.shutdown(True) - await self.clear_events(True) - for child in self._children: - await child.close(True) - return - # Graceful: prevent further gets/puts via shutdown, then wait for drain and children - self.queue.shutdown(False) - await asyncio.gather( - self.queue.join(), *(child.close() for child in self._children) - ) - # Otherwise, join the queue - else: - if immediate: - await self.clear_events(True) - for child in self._children: - await child.close(immediate) - return - await asyncio.gather( - self.queue.join(), *(child.close() for child in self._children) - ) + self._is_closed = True + + self.queue.shutdown(immediate) + + await asyncio.gather( + *(child.close(immediate) for child in self._children) + ) + if not immediate: + await self.queue.join() def is_closed(self) -> bool: """Checks if the queue is closed.""" @@ -234,15 +227,8 @@ async def clear_events(self, clear_child_queues: bool = True) -> None: cleared_count += 1 except asyncio.QueueEmpty: pass - except Exception as e: - # Handle Python 3.13+ QueueShutDown - if ( - sys.version_info >= (3, 13) - and type(e).__name__ == 'QueueShutDown' - ): - pass - else: - raise + except QueueShutDown: + pass if cleared_count > 0: logger.debug( diff --git a/tests/server/events/__init__.py b/tests/server/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/events/test_event_consumer.py b/tests/server/events/test_event_consumer.py index 9a95de328..77a350272 100644 --- a/tests/server/events/test_event_consumer.py +++ b/tests/server/events/test_event_consumer.py @@ -5,7 +5,10 @@ import pytest -from a2a.server.events.event_consumer import EventConsumer, QueueClosed +from pydantic import ValidationError + +from a2a.server.events.event_consumer import EventConsumer +from a2a.server.events.event_queue import QueueShutDown from a2a.server.events.event_queue import EventQueue from a2a.server.jsonrpc_models import JSONRPCError from a2a.types import ( @@ -254,9 +257,9 @@ async def test_consume_all_raises_stored_exception( async def test_consume_all_stops_on_queue_closed_and_confirmed_closed( event_consumer: EventConsumer, mock_event_queue: AsyncMock ): - """Test consume_all stops if QueueClosed is raised and queue.is_closed() is True.""" - # Simulate the queue raising QueueClosed (which is asyncio.QueueEmpty or QueueShutdown) - mock_event_queue.dequeue_event.side_effect = QueueClosed( + """Test consume_all stops if QueueShutDown is raised and queue.is_closed() is True.""" + # Simulate the queue raising QueueShutDown (which is asyncio.QueueEmpty or QueueShutdown) + mock_event_queue.dequeue_event.side_effect = QueueShutDown( 'Queue is empty/closed' ) # Simulate the queue confirming it's closed @@ -268,7 +271,7 @@ async def test_consume_all_stops_on_queue_closed_and_confirmed_closed( assert ( len(consumed_events) == 0 - ) # No events should be consumed as it breaks on QueueClosed + ) # No events should be consumed as it breaks on QueueShutDown mock_event_queue.dequeue_event.assert_called_once() # Should attempt to dequeue once mock_event_queue.is_closed.assert_called_once() # Should check if closed @@ -277,28 +280,28 @@ async def test_consume_all_stops_on_queue_closed_and_confirmed_closed( async def test_consume_all_continues_on_queue_empty_if_not_really_closed( event_consumer: EventConsumer, mock_event_queue: AsyncMock ): - """Test that QueueClosed with is_closed=False allows loop to continue via timeout.""" + """Test that QueueShutDown with is_closed=False allows loop to continue via timeout.""" final_event = create_sample_message(message_id='final_event_id') # Setup dequeue_event behavior: - # 1. Raise QueueClosed (e.g., asyncio.QueueEmpty) + # 1. Raise QueueShutDown (e.g., asyncio.QueueEmpty) # 2. Return the final_event - # 3. Raise QueueClosed again (to terminate after final_event) + # 3. Raise QueueShutDown again (to terminate after final_event) dequeue_effects = [ - QueueClosed('Simulated temporary empty'), + QueueShutDown('Simulated temporary empty'), final_event, - QueueClosed('Queue closed after final event'), + QueueShutDown('Queue closed after final event'), ] mock_event_queue.dequeue_event.side_effect = dequeue_effects # Setup is_closed behavior: - # 1. False when QueueClosed is first raised (so loop doesn't break) - # 2. True after final_event is processed and QueueClosed is raised again + # 1. False when QueueShutDown is first raised (so loop doesn't break) + # 2. True after final_event is processed and QueueShutDown is raised again is_closed_effects = [False, True] mock_event_queue.is_closed.side_effect = is_closed_effects # Patch asyncio.wait_for used inside consume_all - # The goal is that the first QueueClosed leads to a TimeoutError inside consume_all, + # The goal is that the first QueueShutDown leads to a TimeoutError inside consume_all, # the loop continues, and then the final_event is fetched. # To reliably test the timeout behavior within consume_all, we adjust the consumer's @@ -313,15 +316,15 @@ async def test_consume_all_continues_on_queue_empty_if_not_really_closed( assert consumed_events[0] == final_event # Dequeue attempts: - # 1. Raises QueueClosed (is_closed=False, leads to TimeoutError, loop continues) + # 1. Raises QueueShutDown (is_closed=False, leads to TimeoutError, loop continues) # 2. Returns final_event (which is a Message, causing consume_all to break) assert ( mock_event_queue.dequeue_event.call_count == 2 ) # Only two calls needed # is_closed calls: - # 1. After first QueueClosed (returns False) - # The second QueueClosed is not reached because Message breaks the loop. + # 1. After first QueueShutDown (returns False) + # The second QueueShutDown is not reached because Message breaks the loop. assert mock_event_queue.is_closed.call_count == 1 @@ -330,13 +333,13 @@ async def test_consume_all_handles_queue_empty_when_closed_python_version_agnost event_consumer: EventConsumer, mock_event_queue: AsyncMock, monkeypatch ): """Ensure consume_all stops with no events when queue is closed and dequeue_event raises asyncio.QueueEmpty (Python version-agnostic).""" - # Make QueueClosed a distinct exception (not QueueEmpty) to emulate py3.13 semantics + # Make QueueShutDown a distinct exception (not QueueEmpty) to emulate py3.13 semantics from a2a.server.events import event_consumer as ec class QueueShutDown(Exception): pass - monkeypatch.setattr(ec, 'QueueClosed', QueueShutDown, raising=True) + monkeypatch.setattr(ec, 'QueueShutDown', QueueShutDown, raising=True) # Simulate queue reporting closed while dequeue raises QueueEmpty mock_event_queue.dequeue_event.side_effect = asyncio.QueueEmpty( @@ -433,9 +436,6 @@ def test_agent_task_callback_not_done_task(event_consumer: EventConsumer): mock_task.exception.assert_not_called() -from pydantic import ValidationError - - @pytest.mark.asyncio async def test_consume_all_handles_validation_error( event_consumer: EventConsumer, mock_event_queue: AsyncMock @@ -459,3 +459,102 @@ async def test_consume_all_handles_validation_error( assert ( 'Invalid event format received' in logger_error_mock.call_args[0][0] ) + + +@pytest.mark.xfail(reason='https://github.com/a2aproject/a2a-python/issues/869') +@pytest.mark.asyncio +async def test_graceful_close_allows_tapped_queues_to_drain() -> None: + + parent_queue = EventQueue(max_queue_size=10) + child_queue = parent_queue.tap() + + fast_consumer_done = asyncio.Event() + + # Producer + async def produce() -> None: + await parent_queue.enqueue_event( + TaskStatusUpdateEvent( + status=TaskStatus(state=TaskState.TASK_STATE_WORKING) + ) + ) + await parent_queue.enqueue_event( + TaskStatusUpdateEvent( + status=TaskStatus(state=TaskState.TASK_STATE_WORKING) + ) + ) + await parent_queue.enqueue_event(Message(message_id='final')) + + # Fast consumer on parent queue + async def fast_consume() -> list: + consumer = EventConsumer(parent_queue) + events = [event async for event in consumer.consume_all()] + fast_consumer_done.set() + return events + + # Slow consumer on child queue + async def slow_consume() -> list: + consumer = EventConsumer(child_queue) + events = [] + async for event in consumer.consume_all(): + events.append(event) + # Wait for fast_consume to complete (and trigger close) before + # consuming further events to ensure they aren't prematurely dropped. + await fast_consumer_done.wait() + return events + + # Run producer and consumers + producer_task = asyncio.create_task(produce()) + + fast_task = asyncio.create_task(fast_consume()) + slow_task = asyncio.create_task(slow_consume()) + + await producer_task + fast_events = await fast_task + slow_events = await slow_task + + assert len(fast_events) == 3 + assert len(slow_events) == 3 + + +@pytest.mark.xfail( + reason='https://github.com/a2aproject/a2a-python/issues/869', + raises=asyncio.TimeoutError, +) +@pytest.mark.asyncio +async def test_background_close_deadlocks_on_trailing_events() -> None: + queue = EventQueue() + + # Producer enqueues a final event, but then enqueues another event + # (e.g., simulating a delayed log message, race condition, or multiple messages). + await queue.enqueue_event(Message(message_id='final')) + await queue.enqueue_event(Message(message_id='trailing_log')) + + # Consumer dequeues 'final' but stops there (e.g. because it is a final event). + event = await queue.dequeue_event() + assert isinstance(event, Message) and event.message_id == 'final' + queue.task_done() + + # Now attempt a graceful close. This demonstrates the deadlock that + # the previous implementation (with background task and clear_parent_events) + # was trying to solve. + await asyncio.wait_for(queue.close(immediate=False), timeout=0.1) + + +@pytest.mark.asyncio +async def test_consume_all_handles_actual_queue_shutdown( + event_consumer: EventConsumer, mock_event_queue: AsyncMock +): + """Ensure consume_all stops when queue is closed and dequeue_event raises the actual QueueShutDown from event_queue.""" + from a2a.server.events.event_queue import QueueShutDown + + mock_event_queue.dequeue_event.side_effect = QueueShutDown( + 'Queue is closed' + ) + mock_event_queue.is_closed.return_value = True + + consumed_events = [] + # This should exit cleanly because consume_all correctly catches the QueueShutDown exception. + async for event in event_consumer.consume_all(): + consumed_events.append(event) + + assert len(consumed_events) == 0 diff --git a/tests/server/events/test_event_queue.py b/tests/server/events/test_event_queue.py index 2f1dc064b..c6eadb87c 100644 --- a/tests/server/events/test_event_queue.py +++ b/tests/server/events/test_event_queue.py @@ -1,16 +1,14 @@ import asyncio -import sys from typing import Any -from unittest.mock import ( - AsyncMock, - MagicMock, - patch, -) import pytest -from a2a.server.events.event_queue import DEFAULT_MAX_QUEUE_SIZE, EventQueue +from a2a.server.events.event_queue import ( + DEFAULT_MAX_QUEUE_SIZE, + EventQueue, + QueueShutDown, +) from a2a.server.jsonrpc_models import JSONRPCError from a2a.types import ( TaskNotFoundError, @@ -48,6 +46,21 @@ def create_sample_task( ) +class QueueJoinWrapper: + """A wrapper to intercept and signal when `queue.join()` is called.""" + + def __init__(self, original: Any, join_reached: asyncio.Event) -> None: + self.original = original + self.join_reached = join_reached + + def __getattr__(self, name: str) -> Any: + return getattr(self.original, name) + + async def join(self) -> None: + self.join_reached.set() + await self.original.join() + + @pytest.fixture def event_queue() -> EventQueue: return EventQueue() @@ -197,7 +210,8 @@ async def test_enqueue_event_propagates_to_children( @pytest.mark.asyncio async def test_enqueue_event_when_closed( - event_queue: EventQueue, expected_queue_closed_exception: type[Exception] + event_queue: EventQueue, + expected_queue_closed_exception: type[Exception], ) -> None: """Test that no event is enqueued if the parent queue is closed.""" await event_queue.close() # Close the queue first @@ -227,14 +241,13 @@ async def test_enqueue_event_when_closed( @pytest.fixture def expected_queue_closed_exception() -> type[Exception]: - if sys.version_info < (3, 13): - return asyncio.QueueEmpty - return asyncio.QueueShutDown + return QueueShutDown @pytest.mark.asyncio async def test_dequeue_event_closed_and_empty_no_wait( - event_queue: EventQueue, expected_queue_closed_exception: type[Exception] + event_queue: EventQueue, + expected_queue_closed_exception: type[Exception], ) -> None: """Test dequeue_event raises QueueEmpty when closed, empty, and no_wait=True.""" await event_queue.close() @@ -249,7 +262,8 @@ async def test_dequeue_event_closed_and_empty_no_wait( @pytest.mark.asyncio async def test_dequeue_event_closed_and_empty_waits_then_raises( - event_queue: EventQueue, expected_queue_closed_exception: type[Exception] + event_queue: EventQueue, + expected_queue_closed_exception: type[Exception], ) -> None: """Test dequeue_event raises QueueEmpty eventually when closed, empty, and no_wait=False.""" await event_queue.close() @@ -265,8 +279,6 @@ async def test_dequeue_event_closed_and_empty_waits_then_raises( # However, the current code: # async with self._lock: # if self._is_closed and self.queue.empty(): - # logger.warning('Queue is closed. Event will not be dequeued.') - # raise asyncio.QueueEmpty('Queue is closed.') # event = await self.queue.get() -> this line is not reached if closed and empty. # So, for the current implementation, it will raise QueueEmpty immediately. @@ -278,7 +290,6 @@ async def test_dequeue_event_closed_and_empty_waits_then_raises( # For now, testing the current behavior. # Example of a timeout test if it were to wait: # with pytest.raises(asyncio.TimeoutError): # Or QueueEmpty if that's what join/shutdown causes get() to raise - # await asyncio.wait_for(event_queue.dequeue_event(no_wait=False), timeout=0.01) @pytest.mark.asyncio @@ -297,108 +308,12 @@ async def test_tap_creates_child_queue(event_queue: EventQueue) -> None: assert child_queue.queue.maxsize == DEFAULT_MAX_QUEUE_SIZE -@pytest.mark.asyncio -async def test_close_sets_flag_and_handles_internal_queue_old_python( - event_queue: EventQueue, -) -> None: - """Test close behavior on Python < 3.13 (using queue.join).""" - with patch('sys.version_info', (3, 12, 0)): # Simulate older Python - # Mock queue.join as it's called in older versions - event_queue.queue.join = AsyncMock() # type: ignore[method-assign] - - await event_queue.close() - - assert event_queue.is_closed() is True - event_queue.queue.join.assert_awaited_once() # waited for drain - - -@pytest.mark.asyncio -async def test_close_sets_flag_and_handles_internal_queue_new_python( - event_queue: EventQueue, -) -> None: - """Test close behavior on Python >= 3.13 (using queue.shutdown).""" - with patch('sys.version_info', (3, 13, 0)): - # Inject a stub shutdown method for non-3.13 runtimes - from typing import cast - - queue = cast('Any', event_queue.queue) - queue.shutdown = MagicMock() # type: ignore[attr-defined] - await event_queue.close() - assert event_queue.is_closed() is True - queue.shutdown.assert_called_once_with(False) - - -@pytest.mark.asyncio -async def test_close_graceful_py313_waits_for_join_and_children( - event_queue: EventQueue, -) -> None: - """For Python >=3.13 and immediate=False, close should shut down(False), then wait for join and children.""" - with patch('sys.version_info', (3, 13, 0)): - # Arrange - from typing import cast - - q_any = cast('Any', event_queue.queue) - q_any.shutdown = MagicMock() # type: ignore[attr-defined] - event_queue.queue.join = AsyncMock() # type: ignore[method-assign] - - child = event_queue.tap() - child.close = AsyncMock() # type: ignore[method-assign] - - # Act - await event_queue.close(immediate=False) - - # Assert - event_queue.queue.join.assert_awaited_once() - child.close.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_close_propagates_to_children(event_queue: EventQueue) -> None: - """Test that close() is called on all child queues.""" - child_queue1 = event_queue.tap() - child_queue2 = event_queue.tap() - - # Mock the close method of children to verify they are called - child_queue1.close = AsyncMock() # type: ignore[method-assign] - child_queue2.close = AsyncMock() # type: ignore[method-assign] - - await event_queue.close() - - child_queue1.close.assert_awaited_once() - child_queue2.close.assert_awaited_once() - - @pytest.mark.asyncio async def test_close_idempotent(event_queue: EventQueue) -> None: - """Test that calling close() multiple times doesn't cause errors and only acts once.""" - # Mock the internal queue's join or shutdown to see how many times it's effectively called - with patch( - 'sys.version_info', (3, 12, 0) - ): # Test with older version logic first - event_queue.queue.join = AsyncMock() # type: ignore[method-assign] - await event_queue.close() - assert event_queue.is_closed() is True - event_queue.queue.join.assert_called_once() # Called first time - - # Call close again - await event_queue.close() - assert event_queue.is_closed() is True - event_queue.queue.join.assert_called_once() # Still only called once - - # Reset for new Python version test - event_queue_new = EventQueue() # New queue for fresh state - with patch('sys.version_info', (3, 13, 0)): - from typing import cast - - queue = cast('Any', event_queue_new.queue) - queue.shutdown = MagicMock() # type: ignore[attr-defined] - await event_queue_new.close() - assert event_queue_new.is_closed() is True - queue.shutdown.assert_called_once() - - await event_queue_new.close() - assert event_queue_new.is_closed() is True - queue.shutdown.assert_called_once() # Still only called once + await event_queue.close() + assert event_queue.is_closed() is True + await event_queue.close() + assert event_queue.is_closed() is True @pytest.mark.asyncio @@ -514,22 +429,212 @@ async def test_clear_events_empty_queue(event_queue: EventQueue) -> None: @pytest.mark.asyncio async def test_clear_events_closed_queue(event_queue: EventQueue) -> None: """Test clear_events works correctly with closed queue.""" - # Add events and close queue - - with patch('sys.version_info', (3, 12, 0)): # Simulate older Python - # Mock queue.join as it's called in older versions - event_queue.queue.join = AsyncMock() # type: ignore[method-assign] - event = create_sample_message() await event_queue.enqueue_event(event) - await event_queue.close() - # Verify queue is closed but not empty + join_reached = asyncio.Event() + event_queue.queue = QueueJoinWrapper(event_queue.queue, join_reached) + + close_task = asyncio.create_task(event_queue.close(immediate=False)) + await join_reached.wait() + assert event_queue.is_closed() is True assert not event_queue.queue.empty() - # Clear events from closed queue await event_queue.clear_events() - - # Verify queue is now empty + await close_task assert event_queue.queue.empty() + + +@pytest.mark.asyncio +async def test_close_graceful_waits_for_join_and_children( + event_queue: EventQueue, +) -> None: + child = event_queue.tap() + await event_queue.enqueue_event(create_sample_message()) + + join_reached = asyncio.Event() + event_queue.queue = QueueJoinWrapper(event_queue.queue, join_reached) + child.queue = QueueJoinWrapper(child.queue, join_reached) + + close_task = asyncio.create_task(event_queue.close(immediate=False)) + await join_reached.wait() + + assert event_queue.is_closed() + assert child.is_closed() + assert not close_task.done() + + await event_queue.dequeue_event() + event_queue.task_done() + + await child.dequeue_event() + child.task_done() + + await asyncio.wait_for(close_task, timeout=1.0) + + +@pytest.mark.asyncio +async def test_close_propagates_to_children(event_queue: EventQueue) -> None: + child_queue1 = event_queue.tap() + child_queue2 = event_queue.tap() + await event_queue.close() + assert child_queue1.is_closed() + assert child_queue2.is_closed() + + +@pytest.mark.xfail(reason='https://github.com/a2aproject/a2a-python/issues/869') +@pytest.mark.asyncio +async def test_enqueue_close_race_condition() -> None: + queue = EventQueue() + event = create_sample_message() + + enqueue_task = asyncio.create_task(queue.enqueue_event(event)) + close_task = asyncio.create_task(queue.close(immediate=False)) + + try: + results = await asyncio.wait_for( + asyncio.gather(enqueue_task, close_task, return_exceptions=True), + timeout=1.0, + ) + for res in results: + if ( + isinstance(res, Exception) + and type(res).__name__ != 'QueueShutDown' + ): + raise res + except asyncio.TimeoutError: + pytest.fail( + 'Deadlock in close() because enqueue_event put an item after clear_events but before join()' + ) + + +@pytest.mark.asyncio +async def test_event_queue_dequeue_immediate_false( + event_queue: EventQueue, +) -> None: + msg = create_sample_message() + await event_queue.enqueue_event(msg) + # Start close in background so it can wait for join() + close_task = asyncio.create_task(event_queue.close(immediate=False)) + + # The event is still in the queue, we can dequeue it + assert await event_queue.dequeue_event(no_wait=True) == msg + event_queue.task_done() + + await close_task + + # Queue is now empty and closed + with pytest.raises(QueueShutDown): + await event_queue.dequeue_event(no_wait=True) + + +@pytest.mark.asyncio +async def test_event_queue_dequeue_immediate_true( + event_queue: EventQueue, +) -> None: + msg = create_sample_message() + await event_queue.enqueue_event(msg) + await event_queue.close(immediate=True) + # The queue is immediately flushed, so dequeue should raise QueueShutDown + with pytest.raises(QueueShutDown): + await event_queue.dequeue_event(no_wait=True) + + +@pytest.mark.asyncio +async def test_event_queue_enqueue_when_closed(event_queue: EventQueue) -> None: + await event_queue.close(immediate=True) + msg = create_sample_message() + await event_queue.enqueue_event(msg) + # Enqueue should have returned without doing anything + with pytest.raises(QueueShutDown): + await event_queue.dequeue_event(no_wait=True) + + +@pytest.mark.asyncio +async def test_event_queue_shutdown_wakes_getter( + event_queue: EventQueue, +) -> None: + original_queue = event_queue.queue + getter_reached_get = asyncio.Event() + + class QueueWrapper: + def __getattr__(self, name): + return getattr(original_queue, name) + + async def get(self): + getter_reached_get.set() + return await original_queue.get() + + # Replace the underlying queue with a wrapper to intercept `get` + event_queue.queue = QueueWrapper() + + async def getter(): + with pytest.raises(QueueShutDown): + await event_queue.dequeue_event() + + task = asyncio.create_task(getter()) + await getter_reached_get.wait() + + # At this point, getter is guaranteed to be awaiting the original_queue.get() + await event_queue.close(immediate=True) + await asyncio.wait_for(task, timeout=1.0) + + +@pytest.mark.parametrize( + 'immediate, expected_events, close_blocks', + [ + (False, (1, 1), True), + (True, (0, 0), False), + ], +) +@pytest.mark.asyncio +async def test_event_queue_close_behaviors( + event_queue: EventQueue, + immediate: bool, + expected_events: tuple[int, int], + close_blocks: bool, +) -> None: + expected_parent_events, expected_child_events = expected_events + child_queue = event_queue.tap() + + msg = create_sample_message() + await event_queue.enqueue_event(msg) + + # We need deterministic event waiting to prevent sleep() + join_reached = asyncio.Event() + + # Apply wrappers so we know exactly when join() starts + event_queue.queue = QueueJoinWrapper(event_queue.queue, join_reached) + child_queue.queue = QueueJoinWrapper(child_queue.queue, join_reached) + + close_task = asyncio.create_task(event_queue.close(immediate=immediate)) + + if close_blocks: + await join_reached.wait() + assert not close_task.done(), ( + 'close() should block waiting for queue to be drained' + ) + else: + # We await it with a tiny timeout to ensure the task had time to run, + # but because immediate=True, it runs without blocking at all. + await asyncio.wait_for(close_task, timeout=0.1) + assert close_task.done(), 'close() should not block' + + # Verify parent queue state + if expected_parent_events == 0: + with pytest.raises(QueueShutDown): + await event_queue.dequeue_event(no_wait=True) + else: + assert await event_queue.dequeue_event(no_wait=True) == msg + event_queue.task_done() + + # Verify child queue state + if expected_child_events == 0: + with pytest.raises(QueueShutDown): + await child_queue.dequeue_event(no_wait=True) + else: + assert await child_queue.dequeue_event(no_wait=True) == msg + child_queue.task_done() + + # Ensure close_task finishes cleanly + await asyncio.wait_for(close_task, timeout=1.0) diff --git a/uv.lock b/uv.lock index bf6396219..c57876ebf 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ name = "a2a-sdk" source = { editable = "." } dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, { name = "googleapis-common-protos" }, { name = "httpx" }, @@ -106,6 +107,7 @@ requires-dist = [ { name = "alembic", marker = "extra == 'db-cli'", specifier = ">=1.14.0" }, { name = "cryptography", marker = "extra == 'all'", specifier = ">=43.0.0" }, { name = "cryptography", marker = "extra == 'encryption'", specifier = ">=43.0.0" }, + { name = "culsans", marker = "python_full_version < '3.13'", specifier = ">=0.11.0" }, { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115.2" }, { name = "fastapi", marker = "extra == 'http-server'", specifier = ">=0.115.2" }, { name = "google-api-core", specifier = ">=1.26.0" }, @@ -169,6 +171,20 @@ dev = [ { name = "uvicorn", specifier = ">=0.35.0" }, ] +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, +] + [[package]] name = "aiomysql" version = "0.3.2" @@ -711,6 +727,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -2576,6 +2605,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "zipp" version = "3.23.0"