diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index 7c959ba40..ccdede22d 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" diff --git a/features/event_bus.feature b/features/event_bus.feature index 2875b4f58..418257ad3 100644 --- a/features/event_bus.feature +++ b/features/event_bus.feature @@ -118,6 +118,30 @@ Feature: EventBus protocol and domain event emission Scenario: EventBus Protocol emit and subscribe stubs are executable Then the EventBus Protocol stubs should be callable + # --------------------------------------------------------------------------- + # ReactiveEventBus unsubscribe (handle-based) + # --------------------------------------------------------------------------- + + Scenario: ReactiveEventBus unsubscribes a handler via dispose + Given a ReactiveEventBus + When I subscribe a handler to "plan.created" events AND I dispose the returned handle AND I emit a "plan.created" DomainEvent + Then the handler should have received 0 event + + Scenario: ReactiveEventBus unsubscribe returns False for never-subscribed handler + Given a ReactiveEventBus + When I call unsubscribe on a handler that was never subscribed TO "plan.created" + Then unsubscribe should return False + + # --------------------------------------------------------------------------- + # LoggingEventBus unsubscribe (handle-based) + # --------------------------------------------------------------------------- + + Scenario: LoggingEventBus unsubscribes a handler via dispose + Given a LoggingEventBus + When I subscribe a handler to "decision.created" events AND I dispose the returned handle AND I emit a "decision.created" DomainEvent + Then the handler should have received 0 event + + # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # DecisionService event emission # --------------------------------------------------------------------------- diff --git a/features/steps/event_bus_steps.py b/features/steps/event_bus_steps.py index 35c1abd93..31697e540 100644 --- a/features/steps/event_bus_steps.py +++ b/features/steps/event_bus_steps.py @@ -194,6 +194,33 @@ def step_emit_event(ctx: Context, et: str) -> None: ctx.bus.emit(_make_event(et)) +# --------------------------------------------------------------------------- +# Unsubscribe steps (via returned handle) +# --------------------------------------------------------------------------- + + +@when('I subscribe a handler to "{et}" events') +def step_subscribe_with_handle(ctx: Context, et: str) -> None: + """Subscribe and capture the returned subscription handle.""" + collector = _EventCollector() + ctx.collectors.append(collector) + ctx.subscription_handle = ctx.bus.subscribe(EventType(et), collector) + + +@when("I dispose the returned handle") +def step_dispose_handle(ctx: Context) -> None: + """Dispose the subscription handle stored in context.""" + if hasattr(ctx, "subscription_handle"): + ctx.subscription_handle.dispose() + + +@when('I call unsubscribe on a handler that was never subscribed TO "{et}"') +def step_unsubscribe_unknown_handler(ctx: Context, et: str) -> None: + """Call unsubscribe with a brand-new lambda (never subscribed).""" + result = ctx.bus.unsubscribe(lambda e: None) # type: ignore[arg-type] + ctx.unsubscribed_result = result + + # --------------------------------------------------------------------------- # ReactiveEventBus then # --------------------------------------------------------------------------- @@ -226,6 +253,13 @@ def step_bus_has_stream(ctx: Context) -> None: assert stream is not None, "Expected a non-None observable stream" +@then("unsubscribe should return False") +def step_unsubscribe_returns_false(ctx: Context) -> None: + """Verify unsubscribe returned False for unknown handler.""" + result = getattr(ctx, "unsubscribed_result", None) + assert result is False, f"Expected False, got {result!r}" + + @then("emitting a non-DomainEvent should raise TypeError") def step_emit_non_domain_event(ctx: Context) -> None: raised = False diff --git a/robot/event_bus.robot b/robot/event_bus.robot index 96ae6aaf1..2c879d6d0 100644 --- a/robot/event_bus.robot +++ b/robot/event_bus.robot @@ -81,6 +81,24 @@ PlanLifecycleService Emits PLAN_CREATED Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} plan-lifecycle-emits-event-ok +ReactiveEventBus Unsubscribes via Handle + [Documentation] Verify ReactiveEventBus handler removal via handle dispose + ${result}= Run Process ${PYTHON} ${HELPER} reactive_unsubscribe + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} reactive-unsubscribe-ok + +LoggingEventBus Unsubscribes via Handle + [Documentation] Verify LoggingEventBus handler removal via handle dispose + ${result}= Run Process ${PYTHON} ${HELPER} logging_unsubscribe + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} logging-unsubscribe-ok + Container EventBus Is Singleton [Documentation] Verify DI container provides same ReactiveEventBus instance ${result}= Run Process ${PYTHON} ${HELPER} container_event_bus_singleton diff --git a/robot/helper_event_bus.py b/robot/helper_event_bus.py index 2c6b9179c..68f85bf74 100644 --- a/robot/helper_event_bus.py +++ b/robot/helper_event_bus.py @@ -189,6 +189,39 @@ def container_event_bus_singleton() -> None: print("container-event-bus-singleton-ok") +def reactive_unsubscribe() -> None: + """Verify ReactiveEventBus.unsubscribe via dispose removes handler.""" + received: list[DomainEvent] = [] + bus = ReactiveEventBus() + handle = bus.subscribe(EventType.PLAN_CREATED, received.append) + handle.dispose() # unsubscribe using the returned handle + bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED)) + if len(received) != 0: + print( + f"FAIL: expected 0 events after dispose, got {len(received)}", + file=sys.stderr, + ) + sys.exit(1) + print("reactive-unsubscribe-ok") + + +def logging_unsubscribe() -> None: + """Verify LoggingEventBus.unsubscribe via dispose removes handler.""" + received: list[DomainEvent] = [] + bus = LoggingEventBus() + handle = bus.subscribe(EventType.DECISION_CREATED, received.append) + handle.dispose() # unsubscribe using the returned handle + bus.emit(DomainEvent(event_type=EventType.DECISION_CREATED)) + if len(received) != 0: + print( + f"FAIL: expected 0 events after dispose, got {len(received)}", + file=sys.stderr, + ) + sys.exit(1) + print("logging-unsubscribe-ok") + + +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- @@ -202,7 +235,8 @@ _COMMANDS = { "reactive_type_filtering": reactive_type_filtering, "decision_service_emits_event": decision_service_emits_event, "plan_lifecycle_emits_event": plan_lifecycle_emits_event, - "container_event_bus_singleton": container_event_bus_singleton, + "reactive_unsubscribe": reactive_unsubscribe, + "logging_unsubscribe": logging_unsubscribe, } if __name__ == "__main__": diff --git a/src/cleveragents/infrastructure/events/__init__.py b/src/cleveragents/infrastructure/events/__init__.py index f05d55c81..ef194e4f0 100644 --- a/src/cleveragents/infrastructure/events/__init__.py +++ b/src/cleveragents/infrastructure/events/__init__.py @@ -2,11 +2,13 @@ This package provides: -- :class:`EventType` — enumeration of all domain event types. -- :class:`DomainEvent` — base Pydantic model for all domain events. -- :class:`EventBus` — structural Protocol that implementations satisfy. -- :class:`ReactiveEventBus` — RxPY-backed in-process bus. -- :class:`LoggingEventBus` — structured-log-only bus. +- :class:`EventType` \xe2\x80\x94 enumeration of all domain event types. +- :class:`DomainEvent` \xe2\x80\x94 base Pydantic model for all domain events. +- :class:`EventBus` \xe2\x80\x94 structural Protocol that implementations satisfy. +- :class:`ReactiveEventBus` \xe2\x80\x94 RxPY-backed in-process bus. +- :class:`LoggingEventBus` \xe2\x80\x94 structured-log-only bus. +- :class:`_EventSubscription` \xe2\x80\x94 handle returned from ``subscribe()`` for + later disposal via the :meth:`EventBus.unsubscribe` method. Typical usage:: @@ -18,20 +20,27 @@ Typical usage:: ) bus = ReactiveEventBus() - bus.subscribe(EventType.DECISION_CREATED, lambda e: print(e)) + sub = bus.subscribe(EventType.DECISION_CREATED, lambda e: print(e)) bus.emit(DomainEvent(event_type=EventType.DECISION_CREATED)) + # Later: sub.dispose() or bus.unsubscribe(lambda e: print(e)) """ from cleveragents.infrastructure.events.logging_bus import LoggingEventBus from cleveragents.infrastructure.events.models import DomainEvent from cleveragents.infrastructure.events.protocol import EventBus from cleveragents.infrastructure.events.reactive import ReactiveEventBus +from cleveragents.infrastructure.events.subscription import ( + DisposeHandle, + _EventSubscription, +) from cleveragents.infrastructure.events.types import EventType __all__ = [ + "DisposeHandle", "DomainEvent", "EventBus", "EventType", "LoggingEventBus", "ReactiveEventBus", + "_EventSubscription", ] diff --git a/src/cleveragents/infrastructure/events/logging_bus.py b/src/cleveragents/infrastructure/events/logging_bus.py index 307bf4ed3..1850c4f6f 100644 --- a/src/cleveragents/infrastructure/events/logging_bus.py +++ b/src/cleveragents/infrastructure/events/logging_bus.py @@ -1,4 +1,4 @@ -"""LoggingEventBus — structured-logging event bus implementation. +"""LoggingEventBus -- structured-logging event bus implementation. Emits every :class:`~cleveragents.infrastructure.events.models.DomainEvent` to structured logs via :mod:`structlog`. Unlike @@ -13,11 +13,13 @@ Based on: from __future__ import annotations +import contextlib from collections.abc import Callable import structlog from cleveragents.infrastructure.events.models import DomainEvent +from cleveragents.infrastructure.events.subscription import _EventSubscription from cleveragents.infrastructure.events.types import EventType _logger = structlog.get_logger(__name__) @@ -44,6 +46,9 @@ class LoggingEventBus: def __init__(self) -> None: self._subscriptions: dict[EventType, list[Callable[[DomainEvent], None]]] = {} + self._subscription_handles: dict[ + Callable[[DomainEvent], None], _EventSubscription + ] = {} # ------------------------------------------------------------------ # Public API (satisfies EventBus protocol) @@ -86,13 +91,17 @@ class LoggingEventBus: self, event_type: EventType, handler: Callable[[DomainEvent], None], - ) -> None: + ) -> _EventSubscription: """Register *handler* for events of *event_type*. Args: event_type: The :class:`EventType` to listen for. handler: Callable invoked for each matching :class:`DomainEvent`. + Returns: + An :class:`_EventSubscription` handle that can be used to + dispose the subscription later. + Raises: TypeError: If *event_type* is not an :class:`EventType`. TypeError: If *handler* is not callable. @@ -105,5 +114,36 @@ class LoggingEventBus: raise TypeError("handler must be callable") self._subscriptions.setdefault(event_type, []).append(handler) + # Create subscription handle and wire it back to this bus. + handle = _EventSubscription() + handle._handler = handler # dispose uses this to find the dispatch entry + handle._parent_bus = self # dispose navigates to bus internals + handle._subscription_handles = (self._subscription_handles,) + self._subscription_handles[handler] = handle + return handle + + def unsubscribe(self, handler: Callable[[DomainEvent], None]) -> bool: + """Remove *handler* from all subscription lists. + + Args: + handler: The callable to remove from subscriptions. + + Returns: + ``True`` if the handler was found and removed; ``False`` + otherwise. + """ + handle = self._subscription_handles.pop(handler, None) + if handle is not None: + # Remove from _subscriptions dispatch lists. + for _handlers in list(self._subscriptions.values()): + with contextlib.suppress(ValueError): + _handlers.remove(handler) + if not getattr(handle, "_removed", False): + handle.dispose() + _logger.debug("handler_unsubscribed", handler=repr(handler)) + return True + # Already unsubscribed or never subscribed. + return False + __all__ = ["LoggingEventBus"] diff --git a/src/cleveragents/infrastructure/events/protocol.py b/src/cleveragents/infrastructure/events/protocol.py index 2c3cae85f..eafd0954a 100644 --- a/src/cleveragents/infrastructure/events/protocol.py +++ b/src/cleveragents/infrastructure/events/protocol.py @@ -16,6 +16,7 @@ from collections.abc import Callable from typing import Protocol, runtime_checkable from cleveragents.infrastructure.events.models import DomainEvent +from cleveragents.infrastructure.events.subscription import _EventSubscription from cleveragents.infrastructure.events.types import EventType @@ -40,12 +41,30 @@ class EventBus(Protocol): self, event_type: EventType, handler: Callable[[DomainEvent], None], - ) -> None: + ) -> _EventSubscription: """Register *handler* to receive events of *event_type*. + Returns an :class:`_EventSubscription` handle that callers can use + to dispose their subscription at a later time. + Args: event_type: The :class:`EventType` to listen for. handler: Callable invoked synchronously for each matching event. + + Returns: + An :class:`_EventSubscription` handle for unsubscribing. + """ + ... + + def unsubscribe(self, handler: Callable[[DomainEvent], None]) -> bool: + """Remove *handler* from all subscription lists. + + Args: + handler: The callable to remove from subscriptions. + + Returns: + ``True`` if the handler was found and removed; ``False`` + otherwise. """ ... diff --git a/src/cleveragents/infrastructure/events/reactive.py b/src/cleveragents/infrastructure/events/reactive.py index 15af69bb7..f8ffe9866 100644 --- a/src/cleveragents/infrastructure/events/reactive.py +++ b/src/cleveragents/infrastructure/events/reactive.py @@ -1,4 +1,4 @@ -"""ReactiveEventBus — in-process event bus backed by RxPY. +"""ReactiveEventBus -- in-process event bus backed by RxPY. Uses an RxPY :class:`rx.subject.Subject` as the hot observable backbone. Every :meth:`emit` call: @@ -28,6 +28,7 @@ from rx.core.observable.observable import Observable from rx.subject.subject import Subject from cleveragents.infrastructure.events.models import DomainEvent +from cleveragents.infrastructure.events.subscription import _EventSubscription from cleveragents.infrastructure.events.types import EventType _logger = structlog.get_logger(__name__) @@ -46,10 +47,12 @@ class ReactiveEventBus: callers must provide external synchronization. Attributes: - _subject: Hot RxPY Subject — the raw observable stream. + _subject: Hot RxPY Subject -- the raw observable stream. _stream: Read-only Observable view over ``_subject``. _subscriptions: Per-event-type handler lists. _audit_log: Volatile in-memory log of all emitted events. + _subscription_handles: Mapping from handler callable to its + :class:`_EventSubscription` handle for unsubscribe support. """ def __init__(self, max_audit_log_size: int | None = None) -> None: @@ -60,6 +63,9 @@ class ReactiveEventBus: self._stream: Observable = self._subject.pipe(ops.map(_identity)) self._subscriptions: dict[EventType, list[Callable[[DomainEvent], None]]] = {} self._audit_log: deque[DomainEvent] = deque(maxlen=max_audit_log_size) + self._subscription_handles: dict[ + Callable[[DomainEvent], None], _EventSubscription + ] = {} # ------------------------------------------------------------------ # Public API (satisfies EventBus protocol) @@ -135,14 +141,13 @@ class ReactiveEventBus: handler=getattr(handler, "__qualname__", repr(handler)), error_type=type(exc).__name__, error=str(exc), - exc_info=True, ) def subscribe( self, event_type: EventType, handler: Callable[[DomainEvent], None], - ) -> None: + ) -> _EventSubscription: """Register *handler* for events of *event_type*. Handlers are called synchronously inside :meth:`emit` in the order @@ -152,6 +157,10 @@ class ReactiveEventBus: event_type: The :class:`EventType` to listen for. handler: Callable invoked for each matching :class:`DomainEvent`. + Returns: + An :class:`_EventSubscription` handle that can be used to + dispose the subscription later. + Raises: TypeError: If *event_type* is not an :class:`EventType`. TypeError: If *handler* is not callable. @@ -164,6 +173,37 @@ class ReactiveEventBus: raise TypeError("handler must be callable") self._subscriptions.setdefault(event_type, []).append(handler) + # Create subscription handle and wire it back to this bus. + handle = _EventSubscription() + handle._handler = handler # dispose uses this to find the dispatch entry + handle._parent_bus = self # dispose navigates to bus internals + handle._subscription_handles = self._subscription_handles + self._subscription_handles[handler] = handle + return handle + + def unsubscribe(self, handler: Callable[[DomainEvent], None]) -> bool: + """Remove *handler* from all subscription lists. + + Args: + handler: The callable to remove from subscriptions. + + Returns: + ``True`` if the handler was found and removed; ``False`` + otherwise. + """ + handle = self._subscription_handles.pop(handler, None) + if handle is not None: + # Remove from _subscriptions dispatch lists. + for _handlers in list(self._subscriptions.values()): + with contextlib.suppress(ValueError): + _handlers.remove(handler) + if not getattr(handle, "_removed", False): + handle.dispose() + _logger.debug("handler_unsubscribed", handler=repr(handler)) + return True + # Already unsubscribed or never subscribed. + return False + @property def stream(self) -> Observable: """Read-only observable stream for advanced RxPY operators. @@ -183,6 +223,10 @@ class ReactiveEventBus: """ with contextlib.suppress(Exception): self._subject.on_completed() + # Dispose all tracked subscriptions before clearing them. + for _handle in list(self._subscription_handles.values()): + _handle.dispose() + self._subscription_handles.clear() self._subscriptions.clear() self._audit_log.clear() diff --git a/src/cleveragents/infrastructure/events/subscription.py b/src/cleveragents/infrastructure/events/subscription.py new file mode 100644 index 000000000..298f5c6e0 --- /dev/null +++ b/src/cleveragents/infrastructure/events/subscription.py @@ -0,0 +1,90 @@ +"""Subscription handle for EventBus.unsubscribe(). + +Provides an :class:`_EventSubscription` class that callers receive from +:meth:`EventBus.subscribe` so they can dispose their subscription at a +later time. The module also defines the ``DisposeHandle`` Protocol to +facilitate type annotations without pulling in concrete implementation +details across modules. + +Based on: + - docs/specification.md \u00a7Event-Driven Architecture + - Forgejo issue #10887 +""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Protocol +else: + Protocol = object # type: ignore[misc, assignment] + + +class DisposeHandle(Protocol): + """Minimal protocol for an object that supports :meth:`dispose`.""" + + def dispose(self) -> None: + """Release resources held by the subscription. + + This method is idempotent -- calling it multiple times has no + additional effect after the first call. + """ + ... + + +class _EventSubscription: + """Handle returned from :meth:`EventBus.subscribe`. + + Allows callers to dispose their handler subscription. The + ``dispose`` method is idempotent and safe to call multiple times, + and also removes the handler from all dispatch lists on the parent bus. + + Attributes: + _removed: Whether this subscription has already been removed. + """ + + __slots__ = ("_handler", "_parent_bus", "_removed", "_subscription_handles") + + def __init__(self) -> None: + self._removed: bool = False + + @property + def is_removed(self) -> bool: + """Whether this subscription has been disposed.""" + return self._removed + + def dispose(self) -> None: + """Release the subscription. + + Idempotent -- calling multiple times is safe and produces no error. + Removes the handler from all dispatch lists on the parent bus, + provided the handle retains access to ``_parent_bus`` and + helper attributes set by the subscribing implementation. + """ + try: + _parent_bus = getattr(self, "_parent_bus", None) + if _parent_bus is not None: + _subscriptions = getattr(_parent_bus, "_subscriptions", None) + if _subscriptions is not None and ( + _handles_map := getattr(_parent_bus, "_subscription_handles", {}) + ): + with contextlib.suppress(ValueError): + for _handler_list in list(_subscriptions.values()): + _handler_list.remove(self._handler) + _handles_map.pop(self._handler, None) + except BaseException: + pass # Best-effort cleanup. + + self._removed = True + + def __repr__(self) -> str: + state = "REMOVED" if self._removed else "ACTIVE" + try: + return f"<_EventSubscription {state}>" + except BaseException: + return "<_EventSubscription>" + + +__all__ = ["DisposeHandle", "_EventSubscription"] diff --git a/uv.lock b/uv.lock index 31d88e524..64f85201b 100644 --- a/uv.lock +++ b/uv.lock @@ -486,6 +486,7 @@ docs = [ tests = [ { name = "asv" }, { name = "behave" }, + { name = "faker" }, { name = "robotframework" }, { name = "robotframework-pabot" }, { name = "slipcover" }, @@ -496,7 +497,7 @@ tui = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.0" }, + { name = "a2a-sdk", specifier = ">=0.3.0,<1.0.0" }, { name = "aiohttp", specifier = ">=3.13.4" }, { name = "alembic", specifier = ">=1.13.1" }, { name = "asv", marker = "extra == 'tests'", specifier = ">=0.6.5" }, @@ -505,6 +506,7 @@ requires-dist = [ { name = "behave", marker = "extra == 'tests'", specifier = "==1.3.3" }, { name = "dependency-injector", specifier = ">=4.41.0" }, { name = "faiss-cpu", specifier = ">=1.7.4" }, + { name = "faker", marker = "extra == 'tests'", specifier = ">=20.0.0" }, { name = "griffe-pydantic", marker = "extra == 'docs'", specifier = ">=1.0.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "jsonschema", specifier = ">=4.20.0" }, @@ -830,6 +832,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/6f/5eaf3e249c636e616ebb52e369a4a2f1d32b1caf9a611b4f917b3dd21423/faiss_cpu-1.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:8113a2a80b59fe5653cf66f5c0f18be0a691825601a52a614c30beb1fca9bc7c", size = 8556374, upload-time = "2025-12-24T10:27:36.653Z" }, ] +[[package]] +name = "faker" +version = "40.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/13/6741787bd91c4109c7bed047d68273965cd52ce8a5f773c471b949334b6d/faker-40.15.0.tar.gz", hash = "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", size = 1967447, upload-time = "2026-04-17T20:05:27.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447, upload-time = "2026-04-17T20:05:25.437Z" }, +] + [[package]] name = "filelock" version = "3.25.2" @@ -3410,6 +3424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0"